diff --git a/build.gradle b/build.gradle index 4f314f3..8cb3af3 100644 --- a/build.gradle +++ b/build.gradle @@ -1,5 +1,6 @@ plugins { id 'java' + id 'war' id 'org.springframework.boot' version '3.2.5' id 'io.spring.dependency-management' version '1.1.4' } @@ -27,17 +28,23 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0' implementation 'jakarta.validation:jakarta.validation-api:3.0.2' + + // JWT + //implementation 'io.jsonwebtoken:jjwt:0.9.1' + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + implementation 'io.jsonwebtoken:jjwt-impl:0.11.5' + implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5' + implementation 'org.springframework.boot:spring-boot-starter-websocket' - // s3 관련 설정 -// implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE' -// implementation 'com.amazonaws:aws-java-sdk-s3:1.12.265' -// implementation 'software.amazon.ion:ion-java:1.0.3'developmentOnly 'org.springframework.boot:spring-boot-devtools' + implementation 'software.amazon.ion:ion-java:1.0.3' + developmentOnly 'org.springframework.boot:spring-boot-devtools' compileOnly 'org.projectlombok:lombok' runtimeOnly 'com.mysql:mysql-connector-j' annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + providedRuntime 'org.springframework.boot:spring-boot-starter-tomcat' } tasks.named('test') { diff --git a/src/main/java/meltingpot/server/ServerApplication.java b/src/main/java/meltingpot/server/ServerApplication.java index 56b5843..48816d5 100644 --- a/src/main/java/meltingpot/server/ServerApplication.java +++ b/src/main/java/meltingpot/server/ServerApplication.java @@ -2,11 +2,18 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.builder.SpringApplicationBuilder; +import org.springframework.boot.web.servlet.support.SpringBootServletInitializer; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; @SpringBootApplication @EnableJpaAuditing -public class ServerApplication { +public class ServerApplication extends SpringBootServletInitializer { + + @Override + protected SpringApplicationBuilder configure(SpringApplicationBuilder application) { + return application.sources(ServerApplication.class); + } public static void main(String[] args) { SpringApplication.run(ServerApplication.class, args); diff --git a/src/main/java/meltingpot/server/auth/controller/AuthController.java b/src/main/java/meltingpot/server/auth/controller/AuthController.java new file mode 100644 index 0000000..abc571e --- /dev/null +++ b/src/main/java/meltingpot/server/auth/controller/AuthController.java @@ -0,0 +1,54 @@ +package meltingpot.server.auth.controller; + +import io.swagger.v3.oas.annotations.Operation; +import jakarta.validation.Valid; +import meltingpot.server.auth.controller.dto.SigninRequestDto; +import meltingpot.server.auth.controller.dto.AccountResponseDto; +import meltingpot.server.util.ResponseCode; +import meltingpot.server.util.ResponseData; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import lombok.RequiredArgsConstructor; +import meltingpot.server.auth.service.AuthService; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +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; + +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("auth") +public class AuthController { + + private final AuthService authService; + private final Logger logger = LoggerFactory.getLogger(this.getClass()); + + // 회원 가입 + + + // 로그인 + @PostMapping("signin") + @Operation(summary="로그인", description="로그인 API 입니다.") + public ResponseEntity> signin( + @RequestBody @Valid SigninRequestDto request + ){ + AccountResponseDto data = authService.signin(request.toServiceDto()); + logger.info("SIGNIN_SUCCESS (200 OK) :: userId = {}, userEmail = {}", + data.getId(), data.getEmail()); + return ResponseData.toResponseEntity(ResponseCode.SIGNIN_SUCCESS, data); + } + + + // 로그아웃 + + // 이메일 인증 + + // 비밀번호 재설정 + + // 토큰 재발급 + + +} diff --git a/src/main/java/meltingpot/server/auth/controller/dto/AccountResponseDto.java b/src/main/java/meltingpot/server/auth/controller/dto/AccountResponseDto.java new file mode 100644 index 0000000..ae5232c --- /dev/null +++ b/src/main/java/meltingpot/server/auth/controller/dto/AccountResponseDto.java @@ -0,0 +1,27 @@ +package meltingpot.server.auth.controller.dto; + +import lombok.Builder; +import lombok.Getter; +import lombok.Setter; +import meltingpot.server.domain.entity.Account; +import meltingpot.server.util.TokenDto; + +@Getter +@Setter +@Builder +public class AccountResponseDto { + private final Long id; + private final String email; + private final String name; + private TokenDto tokenDto; + + public static AccountResponseDto of(Account account) { + return AccountResponseDto.builder() + .id(account.getId()) + .email(account.getUsername()) + .name(account.getName()) + .build(); + } + + +} diff --git a/src/main/java/meltingpot/server/auth/controller/dto/SigninRequestDto.java b/src/main/java/meltingpot/server/auth/controller/dto/SigninRequestDto.java new file mode 100644 index 0000000..1c14865 --- /dev/null +++ b/src/main/java/meltingpot/server/auth/controller/dto/SigninRequestDto.java @@ -0,0 +1,30 @@ +package meltingpot.server.auth.controller.dto; + + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import meltingpot.server.auth.service.dto.SigninServiceDto; + +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class SigninRequestDto { + + @NotBlank(message = "email is required") + private String email; + + @NotBlank(message = "password is required") + private String password; + + public SigninServiceDto toServiceDto() { + return SigninServiceDto.builder() + .username(getEmail()) + .password(getPassword()) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/meltingpot/server/auth/service/AuthService.java b/src/main/java/meltingpot/server/auth/service/AuthService.java new file mode 100644 index 0000000..acf1be5 --- /dev/null +++ b/src/main/java/meltingpot/server/auth/service/AuthService.java @@ -0,0 +1,93 @@ +package meltingpot.server.auth.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import meltingpot.server.exception.ResourceNotFoundException; +import meltingpot.server.config.TokenProvider; +import meltingpot.server.domain.entity.RefreshToken; +import meltingpot.server.domain.entity.Account; +import meltingpot.server.domain.repository.RefreshTokenRepository; +import meltingpot.server.domain.repository.AccountRepository; +import meltingpot.server.auth.controller.dto.AccountResponseDto; +import meltingpot.server.auth.service.dto.SigninServiceDto; +import meltingpot.server.util.AccountUser; +import meltingpot.server.util.ResponseCode; +import meltingpot.server.util.SecurityUtil; +import meltingpot.server.util.TokenDto; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@RequiredArgsConstructor +@Service +@EnableWebSecurity +public class AuthService implements UserDetailsService { + private final AccountRepository accountRepository; + private final AuthenticationManagerBuilder authenticationManagerBuilder; + private final TokenProvider tokenProvider; + private final RefreshTokenRepository refreshTokenRepository; + //private final PasswordEncoder passwordEncoder; + private static final String BEARER_HEADER = "Bearer "; + + // 로그인 유저 정보 반환 to @CurrentUser + @Transactional(readOnly = true) + public Account getUserInfo(){ + return accountRepository.findByUsernameAndDeletedIsNull(SecurityUtil.getCurrentUserName()) + .orElseThrow(() -> new ResourceNotFoundException(ResponseCode.ACCOUNT_NOT_FOUND)); + } + + // 로그인시 유저정보 조회하는 메서드 override + @Override + @Transactional(readOnly = true) + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + Account account = accountRepository.findByUsername(username) + .orElseThrow(() -> new UsernameNotFoundException(username)); + return new AccountUser(account); + } + + // 로그인 + @Transactional(rollbackFor = Exception.class) + public AccountResponseDto signin(SigninServiceDto serviceDto){ + + // 1. Login ID/PW 를 기반으로 AuthenticationToken 생성 (미인증 토큰) + UsernamePasswordAuthenticationToken authenticationToken = serviceDto.toAuthentication(); + + // 2. 검증 (사용자 비밀번호 체크) 이 이루어지는 부분 + // authenticate 메서드가 실행이 될 때 loadUserByUsername 메서드가 실행됨 + Authentication authentication = authenticationManagerBuilder.getObject() + .authenticate(authenticationToken); + + // 3. 인증 정보를 기반으로 JWT 토큰 생성 + TokenDto tokenDto = tokenProvider.generateTokenDto(authentication); + + // 4. RefreshToken 저장 + Account account = accountRepository.findByUsername(authentication.getName()) + .orElseThrow(() -> new ResourceNotFoundException(ResponseCode.ACCOUNT_NOT_FOUND)); + RefreshToken refreshToken = RefreshToken.builder() + .account(account) + .tokenValue(tokenDto.getRefreshToken()) + .build(); + + refreshTokenRepository.save(refreshToken); + + //인증된 Authentication를 SecurityContext에 저장 + SecurityContextHolder.getContext().setAuthentication(authentication); + + // 5. 토큰 포함 현재 유저 정보 반환 + AccountResponseDto accountResponseDto = AccountResponseDto.of(getUserInfo()); + accountResponseDto.setTokenDto(tokenDto); + + return accountResponseDto; + + } + +} diff --git a/src/main/java/meltingpot/server/auth/service/dto/SigninServiceDto.java b/src/main/java/meltingpot/server/auth/service/dto/SigninServiceDto.java new file mode 100644 index 0000000..ffedbea --- /dev/null +++ b/src/main/java/meltingpot/server/auth/service/dto/SigninServiceDto.java @@ -0,0 +1,18 @@ +package meltingpot.server.auth.service.dto; + +import lombok.Builder; +import lombok.Getter; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; + +@Getter +@Builder +public class SigninServiceDto { + + private final String username; + private final String password; + + // 미인증 토큰 생성 + public UsernamePasswordAuthenticationToken toAuthentication() { + return new UsernamePasswordAuthenticationToken(getUsername(), getPassword()); + } +} diff --git a/src/main/java/meltingpot/server/chat/service/ChatRoomService.java b/src/main/java/meltingpot/server/chat/service/ChatRoomService.java index f70419b..a29d9db 100644 --- a/src/main/java/meltingpot/server/chat/service/ChatRoomService.java +++ b/src/main/java/meltingpot/server/chat/service/ChatRoomService.java @@ -13,7 +13,7 @@ public class ChatRoomService { private final ChatRoomUserRepository chatRoomUserRepository; public void updateAlarmStatus(Long userId, Long chatRoomId) { - ChatRoomUser chatRoomUser = chatRoomUserRepository.findChatRoomUserByUserIdAndChatRoomId(userId, chatRoomId) + ChatRoomUser chatRoomUser = chatRoomUserRepository.findChatRoomUserByAccountIdAndChatRoomId(userId, chatRoomId) .orElseThrow(() -> new IllegalArgumentException("ChatRoomUser not found")); chatRoomUser.toggleAlarm(); chatRoomUserRepository.save(chatRoomUser); diff --git a/src/main/java/meltingpot/server/config/HttpLogoutSuccessHandler.java b/src/main/java/meltingpot/server/config/HttpLogoutSuccessHandler.java new file mode 100644 index 0000000..becb62b --- /dev/null +++ b/src/main/java/meltingpot/server/config/HttpLogoutSuccessHandler.java @@ -0,0 +1,24 @@ +package meltingpot.server.config; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.logout.LogoutSuccessHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +public class HttpLogoutSuccessHandler implements LogoutSuccessHandler { + + @Override + public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, + Authentication authentication) throws IOException, ServletException { + if (authentication == null) { + response.setStatus(HttpServletResponse.SC_BAD_REQUEST); + } else { + response.setStatus(HttpServletResponse.SC_OK); + } + } +} diff --git a/src/main/java/meltingpot/server/config/JwtAccessDeniedHandler.java b/src/main/java/meltingpot/server/config/JwtAccessDeniedHandler.java new file mode 100644 index 0000000..76d90fb --- /dev/null +++ b/src/main/java/meltingpot/server/config/JwtAccessDeniedHandler.java @@ -0,0 +1,21 @@ +package meltingpot.server.config; + +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 { + // 필요한 권한이 없이 접근하려 할때 401 + response.sendError(HttpServletResponse.SC_UNAUTHORIZED); + } +} diff --git a/src/main/java/meltingpot/server/config/JwtAuthenticationEntryPoint.java b/src/main/java/meltingpot/server/config/JwtAuthenticationEntryPoint.java new file mode 100644 index 0000000..e1dde35 --- /dev/null +++ b/src/main/java/meltingpot/server/config/JwtAuthenticationEntryPoint.java @@ -0,0 +1,35 @@ +package meltingpot.server.config; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import meltingpot.server.exception.UnknownAuthenticationException; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.CredentialsExpiredException; +import org.springframework.security.authentication.DisabledException; +import org.springframework.security.authentication.InternalAuthenticationServiceException; +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 { + // 유효한 자격증명을 제공하지 않고 접근하려 할때 401 + if (authException instanceof BadCredentialsException + || authException instanceof InternalAuthenticationServiceException) { + throw new BadCredentialsException("이메일이나 비밀번호가 맞지 않습니다"); + } else if (authException instanceof DisabledException) { + throw new DisabledException("계정이 비활성화 되었습니다"); + } else if (authException instanceof CredentialsExpiredException) { + throw new CredentialsExpiredException("비밀번호 유효기간이 만료되었습니다"); + } else { + throw new UnknownAuthenticationException("알 수 없는 이유로 로그인에 실패했습니다"); + } + } +} diff --git a/src/main/java/meltingpot/server/config/JwtFilter.java b/src/main/java/meltingpot/server/config/JwtFilter.java new file mode 100644 index 0000000..c4a9b10 --- /dev/null +++ b/src/main/java/meltingpot/server/config/JwtFilter.java @@ -0,0 +1,88 @@ +package meltingpot.server.config; + +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 lombok.RequiredArgsConstructor; +import meltingpot.server.util.ResponseCode; +import meltingpot.server.util.ResponseData; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@RequiredArgsConstructor +@Component +public class JwtFilter extends OncePerRequestFilter { + + public static final String AUTHORIZATION_HEADER = "Authorization"; + public static final String REFRESH_TOKEN_HEADER = "RefreshToken"; + public static final String BEARER_PREFIX = "Bearer"; + + public static final Integer REFRESH_TOKEN_TYPE = 1; + public static final Integer ACCESS_TOKEN_TYPE = 0; + + private final TokenProvider tokenProvider; + private final ObjectMapper objectMapper; + + // 실제 필터링 로직은 doFilterInternal 에 들어감 + // JWT 토큰의 인증 정보를 현재 쓰레드의 SecurityContext 에 저장하는 역할 수행 + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) + throws IOException, ServletException { + + // 1. Request Header 에서 토큰을 꺼냄 + String accessToken = resolveToken(request, ACCESS_TOKEN_TYPE); + + // 2. validateToken 으로 토큰 유효성 검사 + // 정상 토큰이면 해당 토큰으로 Authentication 을 가져와서 SecurityContext 에 저장 + // 권한이 필요하지 않은 요청은 custom jwt filter를 거치지 않도록 설정 + if (request.getRequestURI().contains("/contract") || request.getRequestURI() + .contains("/auth/") || request.getRequestURI().contains("/mail/reset-password") + || request.getRequestURI().contains("/docs") || request.getRequestURI() + .contains("/favicon.ico") || request.getRequestURI().contains("/h2-console") || + request.getRequestURI().contains("/swagger-ui") || + request.getRequestURI().contains("/api-docs")) { + filterChain.doFilter(request, response); + } + // 권한이 필요한 요청은 custom jwt filter를 거치도록 설정 + else { + //token 인증 성공 시 + if (StringUtils.hasText(accessToken) && tokenProvider.validateToken(accessToken)) { + Authentication authentication = tokenProvider.getAuthentication(accessToken); + SecurityContextHolder.getContext().setAuthentication(authentication); + filterChain.doFilter(request, response); + } + //token 인증 실패 시 + else { + SecurityContextHolder.clearContext(); + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.getWriter().write(objectMapper + .writeValueAsString(ResponseData.of(ResponseCode.INVALID_AUTH_TOKEN))); + response.setContentType("application/json"); + } + } + } + + // Request Header 에서 토큰 정보를 꺼내오기 + private String resolveToken(HttpServletRequest request, Integer tokenType) { + String bearerToken; + + if (tokenType.equals(ACCESS_TOKEN_TYPE)) { + bearerToken = request.getHeader(AUTHORIZATION_HEADER); + } else { + bearerToken = request.getHeader(REFRESH_TOKEN_HEADER); + } + + if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) { + return bearerToken.substring(7); + } + return null; + } +} \ No newline at end of file diff --git a/src/main/java/meltingpot/server/config/SecurityConfig.java b/src/main/java/meltingpot/server/config/SecurityConfig.java index ae325f5..55501ad 100644 --- a/src/main/java/meltingpot/server/config/SecurityConfig.java +++ b/src/main/java/meltingpot/server/config/SecurityConfig.java @@ -1,22 +1,50 @@ package meltingpot.server.config; import lombok.RequiredArgsConstructor; +import org.springframework.boot.autoconfigure.security.SecurityProperties; +import org.springframework.boot.autoconfigure.security.reactive.PathRequest; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.builders.WebSecurity; 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.config.annotation.web.configuration.WebSecurityCustomizer; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.security.web.csrf.CsrfFilter; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import org.springframework.web.filter.CharacterEncodingFilter; @Configuration @EnableWebSecurity @RequiredArgsConstructor -@EnableGlobalMethodSecurity(prePostEnabled = true) public class SecurityConfig { + + private final HttpLogoutSuccessHandler logoutSuccessHandler; + private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; + private final JwtAccessDeniedHandler jwtAccessDeniedHandler; + private final JwtFilter jwtFilter; + @Bean - public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { - http.csrf(AbstractHttpConfigurer::disable).authorizeHttpRequests((authorizeRequests) -> authorizeRequests.anyRequest().permitAll()); + protected SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .csrf(AbstractHttpConfigurer::disable) + .sessionManagement((sessionManagement) -> + sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS) + ) + // 권한이 불필요한 api 권한 지정 + .authorizeHttpRequests((authorizeRequests) -> + authorizeRequests.requestMatchers("/swagger", "/swagger-ui.html", "/swagger-ui/**", "/api-docs", "/api-docs/**", "/v3/api-docs/**").permitAll() + .requestMatchers("/auth/**").permitAll() + .anyRequest().authenticated() + ); + + + return http.build(); } diff --git a/src/main/java/meltingpot/server/config/SwaggerConfig.java b/src/main/java/meltingpot/server/config/SwaggerConfig.java index 395682c..f24f622 100644 --- a/src/main/java/meltingpot/server/config/SwaggerConfig.java +++ b/src/main/java/meltingpot/server/config/SwaggerConfig.java @@ -3,21 +3,32 @@ 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 lombok.extern.slf4j.Slf4j; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.security.core.context.SecurityContext; import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import java.util.Arrays; +import java.util.Collections; + @Configuration // 스프링 실행시 설정파일 읽어드리기 위한 어노테이션 @Slf4j public class SwaggerConfig implements WebMvcConfigurer { @Bean - public OpenAPI openAPI() { + public OpenAPI openAPI(){ + SecurityScheme securityScheme = new SecurityScheme() + .type(SecurityScheme.Type.HTTP).scheme("bearer").bearerFormat("JWT") + .in(SecurityScheme.In.HEADER).name("Authorization"); + SecurityRequirement securityRequirement = new SecurityRequirement().addList("bearerAuth"); + return new OpenAPI() - .components(new Components()) - .info(apiInfo()); + .components(new Components().addSecuritySchemes("bearerAuth", securityScheme)) + .security(Arrays.asList(securityRequirement)); } private Info apiInfo() { @@ -34,5 +45,4 @@ public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/webjars/**").addResourceLocations("classpath:/META-INF/resources/webjars/"); } - } \ No newline at end of file diff --git a/src/main/java/meltingpot/server/config/TokenProvider.java b/src/main/java/meltingpot/server/config/TokenProvider.java new file mode 100644 index 0000000..a418717 --- /dev/null +++ b/src/main/java/meltingpot/server/config/TokenProvider.java @@ -0,0 +1,187 @@ +package meltingpot.server.config; + +import io.jsonwebtoken.*; +import meltingpot.server.domain.entity.Account; +import meltingpot.server.domain.entity.RefreshToken; +import meltingpot.server.domain.repository.RefreshTokenRepository; +import meltingpot.server.domain.repository.AccountRepository; +import meltingpot.server.exception.InvalidTokenException; +import meltingpot.server.exception.ResourceNotFoundException; +import meltingpot.server.util.ResponseCode; +import meltingpot.server.util.TokenDto; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; + +import java.security.Key; +import java.util.*; +import java.util.stream.Collectors; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +public class TokenProvider { + + private final RefreshTokenRepository refreshTokenRepository; + private final AccountRepository accountRepository; + + private static final String AUTHORITIES_KEY = "auth"; + + private static final String UUID_KEY = "uuid"; + private static final String BEARER_TYPE = "bearer"; + + private static final long ACCESS_TOKEN_EXPIRE_TIME = (long) 1000 * 60 * 30; // 30분 + private static final long REFRESH_TOKEN_EXPIRE_TIME = (long) 1000 * 60 * 60 * 24 * 30; // 30일 + + private final Key key; + + public TokenProvider(@Value("${jwt.secret}") String secretKey, + RefreshTokenRepository refreshTokenRepository, AccountRepository accountRepository) { + this.refreshTokenRepository = refreshTokenRepository; + this.accountRepository = accountRepository; + byte[] keyBytes = Decoders.BASE64.decode(secretKey); + this.key = Keys.hmacShaKeyFor(keyBytes); + } + + public TokenDto generateTokenDto(Authentication authentication){ + // 권한 가져오기 + String authorities = authentication.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.joining(",")); + + long now = (new Date()).getTime(); + + String uuid = UUID.randomUUID().toString(); + + // Access Token 생성 + Date accessTokenExpiresIn = new Date(now + ACCESS_TOKEN_EXPIRE_TIME); + String accessToken = Jwts.builder() + .setSubject(authentication.getName()) + .claim(AUTHORITIES_KEY, authorities) + .setExpiration(accessTokenExpiresIn) + .signWith(SignatureAlgorithm.HS512, key) + .compact(); + + // Refresh Token 생성 + String refreshToken = Jwts.builder() + .setSubject(authentication.getName()) + .claim(UUID_KEY, uuid) + .setExpiration(new Date(now + REFRESH_TOKEN_EXPIRE_TIME)) + .signWith(SignatureAlgorithm.HS512, key) + .compact(); + + return TokenDto.builder() + .grantType(BEARER_TYPE) + .accessToken(accessToken) + .accessTokenExpiresIn(accessTokenExpiresIn.getTime()) + .refreshToken(refreshToken) + .build(); + } + + + // 재발급 TokenDto 반환 + public TokenDto generateReissuedTokenDto(String accessToken) { + // accessToken에서 username 추출 + String username = parseClaims(accessToken).getSubject(); + // username으로 account 조회 + Account account = accountRepository.findByUsername(username) + .orElseThrow(() -> new ResourceNotFoundException(ResponseCode.ACCOUNT_NOT_FOUND)); + // account에서 account_roles -> authorities로 변환 + String authorities = account.toAuthStringList().stream().collect(Collectors.joining(",")); + + long now = (new Date()).getTime(); + Date accessTokenExpiresIn = new Date(now + ACCESS_TOKEN_EXPIRE_TIME); + String uuid = UUID.randomUUID().toString(); + + // Access Token 생성 + String newAccessToken = Jwts.builder() + .setSubject(username) + .claim(AUTHORITIES_KEY, authorities) + .setExpiration(accessTokenExpiresIn) + .signWith(SignatureAlgorithm.HS512, key) + .compact(); + + // Refresh Token 생성 + String newRefreshToken = Jwts.builder() + .setSubject(username) + .claim(UUID_KEY, uuid) + .setExpiration(new Date(now + REFRESH_TOKEN_EXPIRE_TIME)) + .signWith(SignatureAlgorithm.HS512, key) + .compact(); + + return TokenDto.builder() + .grantType(BEARER_TYPE) + .accessToken(newAccessToken) + .accessTokenExpiresIn(accessTokenExpiresIn.getTime()) + .refreshToken(newRefreshToken) + .build(); + } + + // 재발급한 RefreshToken 저장 + public void updateRefreshToken(String accessToken, String newRefreshToken) { + String username = parseClaims(accessToken).getSubject(); + Account account = accountRepository.findByUsername(username) + .orElseThrow(() -> new ResourceNotFoundException(ResponseCode.ACCOUNT_NOT_FOUND)); + + // 재발급한 refresh token 저장 + RefreshToken refreshToken = RefreshToken.builder() + .account(account) + .tokenValue(newRefreshToken) + .build(); + refreshTokenRepository.save(refreshToken); + } + + + public boolean validateToken(String token) { + try { + Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token); + return true; + } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) { + log.error("잘못된 JWT 서명입니다."); + } catch (ExpiredJwtException e) { + log.info("만료된 JWT 토큰입니다."); + } catch (UnsupportedJwtException e) { + log.error("지원되지 않는 JWT 토큰입니다."); + } catch (IllegalArgumentException e) { + log.error("JWT 토큰이 잘못되었습니다."); + } + return false; + } + + public Authentication getAuthentication(String accessToken) { + // 토큰 복호화 + Claims claims = parseClaims(accessToken); + + if (claims.get(AUTHORITIES_KEY) == null) { + throw new InvalidTokenException(ResponseCode.INVALID_AUTH_TOKEN); + } + + // 클레임에서 권한 정보 가져오기 + Collection authorities = + Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(",")) + .map(SimpleGrantedAuthority::new) + .collect(Collectors.toList()); + + // UserDetails 객체를 만들어서 Authentication 리턴 + UserDetails principal = new User(claims.getSubject(), "", authorities); + + return new UsernamePasswordAuthenticationToken(principal, accessToken, authorities); + } + + + private Claims parseClaims(String token) { + try { + return Jwts.parser().setSigningKey(key).parseClaimsJws(token) + .getBody(); + } catch (ExpiredJwtException e) { + return e.getClaims(); + } + } +} diff --git a/src/main/java/meltingpot/server/config/WebConfig.java b/src/main/java/meltingpot/server/config/WebConfig.java index 7494121..733b08a 100644 --- a/src/main/java/meltingpot/server/config/WebConfig.java +++ b/src/main/java/meltingpot/server/config/WebConfig.java @@ -1,19 +1,24 @@ package meltingpot.server.config; -import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @Configuration -public class WebConfig { - @Bean - public WebMvcConfigurer corsConfigurer() { - return new WebMvcConfigurer() { - @Override - public void addCorsMappings(CorsRegistry registry) { - registry.addMapping("/**").allowedOriginPatterns("*"); - } - }; +public class WebConfig implements WebMvcConfigurer { + + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/**") // 모든 api 요청 + .allowedOrigins("*") // 모든 도메인에서 허용 + .allowedMethods( // 모든 메소드에서 허용 + HttpMethod.GET.name(), + HttpMethod.HEAD.name(), + HttpMethod.POST.name(), + HttpMethod.PUT.name(), + HttpMethod.PATCH.name(), + HttpMethod.DELETE.name() + ); } -} +} \ No newline at end of file diff --git a/src/main/java/meltingpot/server/domain/entity/Account.java b/src/main/java/meltingpot/server/domain/entity/Account.java index a386f2d..0229306 100644 --- a/src/main/java/meltingpot/server/domain/entity/Account.java +++ b/src/main/java/meltingpot/server/domain/entity/Account.java @@ -4,13 +4,16 @@ import lombok.*; import jakarta.validation.constraints.NotNull; import meltingpot.server.domain.entity.common.BaseEntity; +import meltingpot.server.domain.entity.enums.Gender; import org.hibernate.annotations.DynamicInsert; import org.hibernate.annotations.DynamicUpdate; import java.time.LocalDate; import java.time.LocalDateTime; import java.util.ArrayList; +import java.util.Collection; import java.util.List; +import java.util.stream.Collectors; @Getter @Setter @@ -40,7 +43,9 @@ public class Account extends BaseEntity { private String password; @NotNull - private enum gender{ male, female, unknown }; + @NotNull + @Enumerated(EnumType.STRING) + private Gender gender; @NotNull private LocalDate birth; @@ -68,10 +73,19 @@ private enum gender{ male, female, unknown }; @OneToMany(mappedBy = "account", cascade = CascadeType.ALL ) private List profileImages = new ArrayList<>(); + @Builder.Default + @OneToMany(mappedBy = "account") + private List accountRoles = new ArrayList<>(); + + public List toAuthStringList() { + return accountRoles.stream().map(a -> a.getRole().getAuthority()) + .collect(Collectors.toList()); + } + @OneToMany(mappedBy = "account") private List comments = new ArrayList<>(); @OneToMany(mappedBy = "account") private List posts = new ArrayList<>(); -} \ No newline at end of file +} diff --git a/src/main/java/meltingpot/server/domain/entity/AccountRole.java b/src/main/java/meltingpot/server/domain/entity/AccountRole.java new file mode 100644 index 0000000..3417be6 --- /dev/null +++ b/src/main/java/meltingpot/server/domain/entity/AccountRole.java @@ -0,0 +1,26 @@ +package meltingpot.server.domain.entity; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import jakarta.persistence.*; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Entity +@IdClass(AccountRoleId.class) +public class AccountRole { + + @Id + @ManyToOne + @JoinColumn(name = "account_id") + private Account account; + + @Id + @ManyToOne + @JoinColumn(name = "role_id") + private Role role; +} \ No newline at end of file diff --git a/src/main/java/meltingpot/server/domain/entity/AccountRoleId.java b/src/main/java/meltingpot/server/domain/entity/AccountRoleId.java new file mode 100644 index 0000000..72eeb75 --- /dev/null +++ b/src/main/java/meltingpot/server/domain/entity/AccountRoleId.java @@ -0,0 +1,17 @@ +package meltingpot.server.domain.entity; + +import lombok.*; + +import java.io.Serializable; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode +public class AccountRoleId implements Serializable { + + private Long account; + private Integer role; + +} \ No newline at end of file diff --git a/src/main/java/meltingpot/server/domain/entity/Comment.java b/src/main/java/meltingpot/server/domain/entity/Comment.java index d08537b..300a058 100644 --- a/src/main/java/meltingpot/server/domain/entity/Comment.java +++ b/src/main/java/meltingpot/server/domain/entity/Comment.java @@ -23,7 +23,7 @@ public class Comment extends BaseEntity { private Post post; @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "account_id") + @JoinColumn(name = "user_id") private Account account; @ManyToOne(fetch = FetchType.LAZY) diff --git a/src/main/java/meltingpot/server/domain/entity/Post.java b/src/main/java/meltingpot/server/domain/entity/Post.java index 50d7ce5..e4929fb 100644 --- a/src/main/java/meltingpot/server/domain/entity/Post.java +++ b/src/main/java/meltingpot/server/domain/entity/Post.java @@ -31,7 +31,7 @@ public class Post extends BaseEntity { private PostType postType; @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "account_id") + @JoinColumn(name = "user_id") private Account account; @OneToMany(mappedBy = "post") diff --git a/src/main/java/meltingpot/server/domain/entity/PostImage.java b/src/main/java/meltingpot/server/domain/entity/PostImage.java index b9a4b4f..2039d6c 100644 --- a/src/main/java/meltingpot/server/domain/entity/PostImage.java +++ b/src/main/java/meltingpot/server/domain/entity/PostImage.java @@ -24,6 +24,6 @@ public class PostImage extends BaseEntity { private Post post; @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "account_id") + @JoinColumn(name = "user_id") private Account account; } diff --git a/src/main/java/meltingpot/server/domain/entity/RefreshToken.java b/src/main/java/meltingpot/server/domain/entity/RefreshToken.java new file mode 100644 index 0000000..6ff3db7 --- /dev/null +++ b/src/main/java/meltingpot/server/domain/entity/RefreshToken.java @@ -0,0 +1,30 @@ +package meltingpot.server.domain.entity; + +import jakarta.persistence.*; +import jakarta.persistence.Id; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import meltingpot.server.domain.entity.Account; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Entity +public class RefreshToken { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @NotNull + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "account_id") + private Account account; + + private String tokenValue; + +} \ No newline at end of file diff --git a/src/main/java/meltingpot/server/domain/entity/Report.java b/src/main/java/meltingpot/server/domain/entity/Report.java index 6a78c99..6bfa978 100644 --- a/src/main/java/meltingpot/server/domain/entity/Report.java +++ b/src/main/java/meltingpot/server/domain/entity/Report.java @@ -22,6 +22,6 @@ public class Report extends BaseEntity { private Post post; @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "account_id") + @JoinColumn(name = "user_id") private Account account; } diff --git a/src/main/java/meltingpot/server/domain/entity/Role.java b/src/main/java/meltingpot/server/domain/entity/Role.java new file mode 100644 index 0000000..6f4bcb5 --- /dev/null +++ b/src/main/java/meltingpot/server/domain/entity/Role.java @@ -0,0 +1,22 @@ +package meltingpot.server.domain.entity; + +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import jakarta.persistence.*; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Entity +public class Role { + @Id + private Integer id; + + @NotNull + private String authority; + +} diff --git a/src/main/java/meltingpot/server/domain/entity/chat/Chat.java b/src/main/java/meltingpot/server/domain/entity/chat/Chat.java index 9f086ae..a0f0df6 100644 --- a/src/main/java/meltingpot/server/domain/entity/chat/Chat.java +++ b/src/main/java/meltingpot/server/domain/entity/chat/Chat.java @@ -20,6 +20,6 @@ public class Chat extends BaseEntity { private ChatRoom chatRoom; @OneToOne - @JoinColumn(name = "account_id") + @JoinColumn(name = "user_id") private Account account; } diff --git a/src/main/java/meltingpot/server/domain/entity/enums/Gender.java b/src/main/java/meltingpot/server/domain/entity/enums/Gender.java new file mode 100644 index 0000000..9d02174 --- /dev/null +++ b/src/main/java/meltingpot/server/domain/entity/enums/Gender.java @@ -0,0 +1,5 @@ +package meltingpot.server.domain.entity.enums; + +public enum Gender { + MALE, FEMALE, UNKNOWN +} diff --git a/src/main/java/meltingpot/server/domain/entity/party/Party.java b/src/main/java/meltingpot/server/domain/entity/party/Party.java index 3569a7d..bfd9b87 100644 --- a/src/main/java/meltingpot/server/domain/entity/party/Party.java +++ b/src/main/java/meltingpot/server/domain/entity/party/Party.java @@ -10,6 +10,8 @@ import meltingpot.server.domain.entity.party.enums.PartyStatus; import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; @Entity @Getter @@ -22,7 +24,7 @@ public class Party extends BaseEntity { private int id; @NotNull - @ManyToOne(fetch = FetchType.LAZY) + @ManyToOne(fetch = FetchType.EAGER) @JoinColumn(name = "party_owner") private Account account; @@ -59,8 +61,18 @@ public class Party extends BaseEntity { private int partyMinParticipant; @NotNull - private int partMaxParticipant; + @Column(name = "party_max_participant", nullable = false) + private int partyMaxParticipant; + + @OneToMany(mappedBy = "party", fetch = FetchType.EAGER, cascade = CascadeType.REMOVE) + @Builder.Default + private List partyContents = new ArrayList<>(); + + @OneToMany(mappedBy = "party", fetch = FetchType.EAGER, cascade = CascadeType.REMOVE) + @Builder.Default + private List partyParticipants = new ArrayList<>(); - @OneToOne(mappedBy = "party", cascade = CascadeType.ALL) - private ChatRoom chatRoom; + @OneToOne(mappedBy = "party", fetch = FetchType.LAZY, cascade = CascadeType.REMOVE) + @Builder.Default + private ChatRoom chatRoom = null; } diff --git a/src/main/java/meltingpot/server/domain/entity/party/PartyParticipant.java b/src/main/java/meltingpot/server/domain/entity/party/PartyParticipant.java index b92e666..00eeb2b 100644 --- a/src/main/java/meltingpot/server/domain/entity/party/PartyParticipant.java +++ b/src/main/java/meltingpot/server/domain/entity/party/PartyParticipant.java @@ -23,8 +23,8 @@ public class PartyParticipant extends BaseEntity { private Party party; @NotNull - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "account_id") + @ManyToOne(fetch = FetchType.EAGER) + @JoinColumn(name = "user_id") private Account account; @NotNull diff --git a/src/main/java/meltingpot/server/domain/entity/party/PartyWishlist.java b/src/main/java/meltingpot/server/domain/entity/party/PartyWishlist.java index 57c8ddb..442e9f1 100644 --- a/src/main/java/meltingpot/server/domain/entity/party/PartyWishlist.java +++ b/src/main/java/meltingpot/server/domain/entity/party/PartyWishlist.java @@ -23,6 +23,6 @@ public class PartyWishlist extends BaseEntity { @NotNull @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "account_id") + @JoinColumn(name = "user_id") private Account account; } diff --git a/src/main/java/meltingpot/server/domain/entity/party/enums/PartyStatus.java b/src/main/java/meltingpot/server/domain/entity/party/enums/PartyStatus.java index 272493b..d0badaf 100644 --- a/src/main/java/meltingpot/server/domain/entity/party/enums/PartyStatus.java +++ b/src/main/java/meltingpot/server/domain/entity/party/enums/PartyStatus.java @@ -1,5 +1,5 @@ package meltingpot.server.domain.entity.party.enums; public enum PartyStatus { - TEMP_SAVED, RECRUIT_SCHEDULED, RECRUIT_OPEN, RECRUIT_CLOSED, RUNNING, DONE, + TEMP_SAVED, CANCELED, RECRUIT_SCHEDULED, RECRUIT_OPEN, RECRUIT_CLOSED, RUNNING, DONE, } diff --git a/src/main/java/meltingpot/server/domain/repository/AccountRepository.java b/src/main/java/meltingpot/server/domain/repository/AccountRepository.java new file mode 100644 index 0000000..1988a51 --- /dev/null +++ b/src/main/java/meltingpot/server/domain/repository/AccountRepository.java @@ -0,0 +1,14 @@ +package meltingpot.server.domain.repository; + +import meltingpot.server.domain.entity.Account; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface AccountRepository extends JpaRepository { + @EntityGraph(attributePaths = {"accountRoles"}) + Optional findByUsername(String name); + + Optional findByUsernameAndDeletedIsNull(String currentUserName); +} diff --git a/src/main/java/meltingpot/server/domain/repository/RefreshTokenRepository.java b/src/main/java/meltingpot/server/domain/repository/RefreshTokenRepository.java new file mode 100644 index 0000000..637bf80 --- /dev/null +++ b/src/main/java/meltingpot/server/domain/repository/RefreshTokenRepository.java @@ -0,0 +1,22 @@ +package meltingpot.server.domain.repository; +import java.util.Optional; + +import meltingpot.server.domain.entity.RefreshToken; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface RefreshTokenRepository extends JpaRepository { + + Optional findByTokenValue(String tokenValue); + + RefreshToken getByTokenValue(String tokenValue); + + @Modifying + @Query("delete from RefreshToken where tokenValue = :tokenValue") + void deleteByTokenValue(@Param(value = "tokenValue") String tokenValue); + + Boolean existsByTokenValue(String tokenValue); + +} \ No newline at end of file diff --git a/src/main/java/meltingpot/server/domain/repository/party/PartyParticipantRepository.java b/src/main/java/meltingpot/server/domain/repository/party/PartyParticipantRepository.java index 1599e0a..7fc16ce 100644 --- a/src/main/java/meltingpot/server/domain/repository/party/PartyParticipantRepository.java +++ b/src/main/java/meltingpot/server/domain/repository/party/PartyParticipantRepository.java @@ -3,10 +3,5 @@ import meltingpot.server.domain.entity.party.PartyParticipant; import org.springframework.data.jpa.repository.JpaRepository; -import java.util.List; - public interface PartyParticipantRepository extends JpaRepository { - int countByPartyId(int partyId); - int countByAccountId(Long userId); - List findAllByAccountId(Long userId); } diff --git a/src/main/java/meltingpot/server/domain/repository/party/PartyRepository.java b/src/main/java/meltingpot/server/domain/repository/party/PartyRepository.java index 326a02b..9b35225 100644 --- a/src/main/java/meltingpot/server/domain/repository/party/PartyRepository.java +++ b/src/main/java/meltingpot/server/domain/repository/party/PartyRepository.java @@ -3,7 +3,6 @@ import meltingpot.server.domain.entity.party.Party; import org.springframework.data.jpa.repository.JpaRepository; - -public interface PartyRepository extends JpaRepository { +public interface PartyRepository extends JpaRepository { Party findByChatRoomId(Long chatRoomId); } diff --git a/src/main/java/meltingpot/server/exception/InvalidTokenException.java b/src/main/java/meltingpot/server/exception/InvalidTokenException.java new file mode 100644 index 0000000..30f90a4 --- /dev/null +++ b/src/main/java/meltingpot/server/exception/InvalidTokenException.java @@ -0,0 +1,12 @@ +package meltingpot.server.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import meltingpot.server.util.ResponseCode; + +@Getter +@RequiredArgsConstructor +public class InvalidTokenException extends RuntimeException { + + private final ResponseCode responseCode; +} \ No newline at end of file diff --git a/src/main/java/meltingpot/server/exception/ResourceNotFoundException.java b/src/main/java/meltingpot/server/exception/ResourceNotFoundException.java new file mode 100644 index 0000000..c5cec29 --- /dev/null +++ b/src/main/java/meltingpot/server/exception/ResourceNotFoundException.java @@ -0,0 +1,12 @@ +package meltingpot.server.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import meltingpot.server.util.ResponseCode; + +@Getter +@RequiredArgsConstructor +public class ResourceNotFoundException extends RuntimeException { + + private final ResponseCode responseCode; +} diff --git a/src/main/java/meltingpot/server/exception/UnknownAuthenticationException.java b/src/main/java/meltingpot/server/exception/UnknownAuthenticationException.java new file mode 100644 index 0000000..27b15cd --- /dev/null +++ b/src/main/java/meltingpot/server/exception/UnknownAuthenticationException.java @@ -0,0 +1,11 @@ +package meltingpot.server.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class UnknownAuthenticationException extends RuntimeException { + + private final String detail; +} \ No newline at end of file diff --git a/src/main/java/meltingpot/server/party/controller/PartyController.java b/src/main/java/meltingpot/server/party/controller/PartyController.java new file mode 100644 index 0000000..31b5518 --- /dev/null +++ b/src/main/java/meltingpot/server/party/controller/PartyController.java @@ -0,0 +1,52 @@ +package meltingpot.server.party.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import lombok.RequiredArgsConstructor; +import meltingpot.server.domain.entity.Account; +import meltingpot.server.party.dto.PartyResponse; +import meltingpot.server.party.service.PartyService; +import meltingpot.server.util.CurrentUser; +import meltingpot.server.util.ResponseCode; +import meltingpot.server.util.ResponseData; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.NoSuchElementException; + +@RestController +@RequestMapping("/api/v1/party") +@RequiredArgsConstructor +public class PartyController { + private final PartyService partyService; + + @GetMapping("/{partyId}") + @Operation(summary = "파티 정보 조회", description = "파티 ID를 통해 파티 정보를 불러옵니다. 임시 저장된 파티를 불러오는 경우 작성자만 불러올 수 있습니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "OK", description = "파티 정보 조회 성공"), + @ApiResponse(responseCode = "NOT_FOUND", description = "파티 정보를 찾을 수 없습니다") + }) + public ResponseEntity> getParty(@PathVariable int partyId, @CurrentUser Account user) { + try { + return ResponseData.toResponseEntity(ResponseCode.PARTY_FETCH_SUCCESS, partyService.getParty(partyId, user)); + } catch (NoSuchElementException e) { + return ResponseData.toResponseEntity(ResponseCode.PARTY_NOT_FOUND, null); + } + } + + @PostMapping("/{partyId}/join") + @Operation(summary = "파티 참여", description = "파티 ID를 통해 특정 파티에 참여") + @ApiResponses(value = { + @ApiResponse(responseCode = "OK", description = "파티 참여 성공"), + @ApiResponse(responseCode = "NOT_FOUND", description = "파티 정보를 찾을 수 없습니다"), + @ApiResponse(responseCode = "BAD_REQUEST", description = "파티 참여 실패 (이미 참여한 파티, 최대 인원 초과, 모집중이 아닌 파티 등)") + }) + public ResponseEntity joinParty(@PathVariable int partyId, @CurrentUser Account user) { + try { + return ResponseData.toResponseEntity(partyService.joinParty(partyId, user)); + } catch (NoSuchElementException e) { + return ResponseData.toResponseEntity(ResponseCode.PARTY_NOT_FOUND); + } + } +} diff --git a/src/main/java/meltingpot/server/party/dto/PartyContentResponse.java b/src/main/java/meltingpot/server/party/dto/PartyContentResponse.java new file mode 100644 index 0000000..16412cd --- /dev/null +++ b/src/main/java/meltingpot/server/party/dto/PartyContentResponse.java @@ -0,0 +1,17 @@ +package meltingpot.server.party.dto; + +import lombok.Builder; +import meltingpot.server.domain.entity.party.PartyContent; + +@Builder +public record PartyContentResponse( + String lang, + String content +) { + public static PartyContentResponse of(PartyContent partyContent) { + return PartyContentResponse.builder() + .lang(partyContent.getPartyContentLang()) + .content(partyContent.getPartyContent()) + .build(); + } +} diff --git a/src/main/java/meltingpot/server/party/dto/PartyParticipantResponse.java b/src/main/java/meltingpot/server/party/dto/PartyParticipantResponse.java new file mode 100644 index 0000000..74de49a --- /dev/null +++ b/src/main/java/meltingpot/server/party/dto/PartyParticipantResponse.java @@ -0,0 +1,15 @@ +package meltingpot.server.party.dto; + +import lombok.Builder; +import meltingpot.server.domain.entity.party.PartyParticipant; + +@Builder +public record PartyParticipantResponse( + String name +) { + public static PartyParticipantResponse of(PartyParticipant partyParticipant) { + return PartyParticipantResponse.builder() + .name(partyParticipant.getAccount().getName()) + .build(); + } +} diff --git a/src/main/java/meltingpot/server/party/dto/PartyResponse.java b/src/main/java/meltingpot/server/party/dto/PartyResponse.java new file mode 100644 index 0000000..7cd034d --- /dev/null +++ b/src/main/java/meltingpot/server/party/dto/PartyResponse.java @@ -0,0 +1,41 @@ +package meltingpot.server.party.dto; + +import lombok.Builder; +import meltingpot.server.domain.entity.party.Party; +import meltingpot.server.domain.entity.party.enums.PartyStatus; + +import java.util.List; + +@Builder +public record PartyResponse( + int id, + String ownerName, + String subject, + PartyStatus partyStatus, + String startTime, + String locationAddress, + String locationDetail, + Boolean locationReserved, + Boolean locationCanBeChanged, + int minParticipant, + int maxParticipant, + List participants, + List contents +) { + public static PartyResponse of(Party party) { + return PartyResponse.builder().id(party.getId()) + .ownerName(party.getAccount().getName()) + .subject(party.getPartySubject()) + .partyStatus(party.getPartyStatus()) + .startTime(party.getPartyStartTime().toString()) + .locationAddress(party.getPartyLocationAddress()) + .locationDetail(party.getPartyLocationDetail()) + .locationReserved(party.getPartyLocationReserved()) + .locationCanBeChanged(party.getPartyLocationCanBeChanged()) + .minParticipant(party.getPartyMinParticipant()) + .maxParticipant(party.getPartyMinParticipant()) + .participants(party.getPartyParticipants().stream().map(PartyParticipantResponse::of).toList()) + .contents(party.getPartyContents().stream().map(PartyContentResponse::of).toList()) + .build(); + } +} diff --git a/src/main/java/meltingpot/server/party/service/PartyService.java b/src/main/java/meltingpot/server/party/service/PartyService.java new file mode 100644 index 0000000..dab2325 --- /dev/null +++ b/src/main/java/meltingpot/server/party/service/PartyService.java @@ -0,0 +1,67 @@ +package meltingpot.server.party.service; + +import lombok.RequiredArgsConstructor; +import meltingpot.server.domain.entity.Account; +import meltingpot.server.domain.entity.party.PartyParticipant; +import meltingpot.server.domain.entity.party.enums.ParticipantStatus; +import meltingpot.server.domain.entity.party.enums.PartyStatus; +import meltingpot.server.domain.repository.party.PartyParticipantRepository; +import meltingpot.server.domain.repository.party.PartyRepository; +import meltingpot.server.party.dto.PartyResponse; +import meltingpot.server.util.ResponseCode; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import meltingpot.server.domain.entity.party.Party; + +import java.util.List; +import java.util.NoSuchElementException; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class PartyService { + private final PartyRepository partyRepository; + private final PartyParticipantRepository partyParticipantRepository; + + @Transactional + public PartyResponse getParty(int partyId, Account user) { + Party party = partyRepository.findById(partyId).orElseThrow(); + PartyStatus partyStatus = party.getPartyStatus(); + + boolean isPartyOwner = party.getAccount().equals(user); + + if ((!isPartyOwner && partyStatus == PartyStatus.TEMP_SAVED) || partyStatus == PartyStatus.CANCELED) { + throw new NoSuchElementException(); + } + + return PartyResponse.of(party); + } + + @Transactional + public ResponseCode joinParty(int partyId, Account user) { + Party party = partyRepository.findById(partyId).orElseThrow(); + PartyStatus partyStatus = party.getPartyStatus(); + + if (partyStatus != PartyStatus.RECRUIT_OPEN) { + return ResponseCode.PARTY_NOT_OPEN; + } + + List partyParticipants = party.getPartyParticipants().stream().filter((participant) -> participant.getParticipantStatus() != ParticipantStatus.CANCELED).toList(); + if (partyParticipants.stream().anyMatch((participant) -> participant.getParticipantStatus() != ParticipantStatus.CANCELED && participant.getAccount().equals(user))) { + return ResponseCode.PARTY_ALREADY_JOINED; + } + + if (partyParticipants.size() >= party.getPartyMaxParticipant()) { + return ResponseCode.PARTY_FULL; + } + + PartyParticipant partyParticipant = PartyParticipant.builder() + .party(party) + .account(user) + .participantStatus(ParticipantStatus.APPROVED) + .build(); + partyParticipantRepository.save(partyParticipant); + + return ResponseCode.PARTY_JOIN_SUCCESS; + } +} diff --git a/src/main/java/meltingpot/server/util/AccountUser.java b/src/main/java/meltingpot/server/util/AccountUser.java new file mode 100644 index 0000000..7cc3fae --- /dev/null +++ b/src/main/java/meltingpot/server/util/AccountUser.java @@ -0,0 +1,14 @@ +package meltingpot.server.util; + +import java.util.stream.Collectors; +import meltingpot.server.domain.entity.Account; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.User; + +public class AccountUser extends User { + public AccountUser(Account account) { + super(account.getUsername(), account.getPassword(), + account.toAuthStringList().stream().map(SimpleGrantedAuthority::new) + .collect(Collectors.toList())); + } +} \ No newline at end of file diff --git a/src/main/java/meltingpot/server/util/CurrentUser.java b/src/main/java/meltingpot/server/util/CurrentUser.java new file mode 100644 index 0000000..f066554 --- /dev/null +++ b/src/main/java/meltingpot/server/util/CurrentUser.java @@ -0,0 +1,17 @@ +package meltingpot.server.util; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import org.springframework.security.core.annotation.AuthenticationPrincipal; + +@Target({ElementType.PARAMETER, ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@AuthenticationPrincipal(expression = "@authService.getUserInfo()") +//인증된 사용자의 Principal 정보를 참조할 수 있다. +public @interface CurrentUser { + +} diff --git a/src/main/java/meltingpot/server/util/ResponseCode.java b/src/main/java/meltingpot/server/util/ResponseCode.java index e0f3fe2..11a46b2 100644 --- a/src/main/java/meltingpot/server/util/ResponseCode.java +++ b/src/main/java/meltingpot/server/util/ResponseCode.java @@ -13,6 +13,8 @@ public enum ResponseCode { SIGNIN_SUCCESS(OK, "로그인 성공"), SIGNOUT_SUCCESS(OK, "로그아웃 성공"), REISSUE_TOKEN_SUCCESS(OK, "토큰 재발급 성공"), + PARTY_FETCH_SUCCESS(OK, "파티 정보 불러오기 성공"), + PARTY_JOIN_SUCCESS(OK, "파티 참여 성공"), /* 201 CREATED : 요청 성공, 자원 생성 */ @@ -25,6 +27,9 @@ public enum ResponseCode { /* 400 BAD_REQUEST : 잘못된 요청 */ MAIL_SEND_FAIL(BAD_REQUEST, "메일 전송 실패"), AUTH_NUMBER_INCORRECT(BAD_REQUEST, "인증 번호가 옳지 않습니다"), + PARTY_NOT_OPEN(BAD_REQUEST, "모집중인 파티가 아닙니다"), + PARTY_FULL(BAD_REQUEST, "파티 인원이 가득 찼습니다"), + PARTY_ALREADY_JOINED(BAD_REQUEST, "이미 참여한 파티입니다"), /* 401 UNAUTHORIZED : 인증되지 않은 사용자 */ @@ -44,6 +49,7 @@ public enum ResponseCode { /* 404 NOT_FOUND : Resource 를 찾을 수 없음 */ ACCOUNT_NOT_FOUND(NOT_FOUND, "계정 정보를 찾을 수 없습니다"), REFRESH_TOKEN_NOT_FOUND(NOT_FOUND, "REFRESH 토큰 정보를 찾을 수 없습니다"), + PARTY_NOT_FOUND(NOT_FOUND, "파티 정보를 찾을 수 없습니다"), /* 409 CONFLICT : Resource 의 현재 상태와 충돌. 보통 중복된 데이터 존재 */ diff --git a/src/main/java/meltingpot/server/util/SecurityUtil.java b/src/main/java/meltingpot/server/util/SecurityUtil.java new file mode 100644 index 0000000..4754649 --- /dev/null +++ b/src/main/java/meltingpot/server/util/SecurityUtil.java @@ -0,0 +1,21 @@ +package meltingpot.server.util; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; + +@Slf4j +public class SecurityUtil { + // SecurityContext 에 유저 정보가 저장되는 시점 + // Request 가 들어올 때 JwtFilter 의 doFilter 에서 저장 + public static String getCurrentUserName() { + final Authentication authentication = SecurityContextHolder.getContext() + .getAuthentication(); + + if (authentication == null || authentication.getName() == null) { + throw new IllegalArgumentException("Security Context 에 인증 정보가 없습니다."); + } + + return authentication.getName(); + } +} diff --git a/src/main/java/meltingpot/server/util/TokenDto.java b/src/main/java/meltingpot/server/util/TokenDto.java new file mode 100644 index 0000000..ca4bda0 --- /dev/null +++ b/src/main/java/meltingpot/server/util/TokenDto.java @@ -0,0 +1,14 @@ +package meltingpot.server.util; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class TokenDto { + + private String grantType; + private String accessToken; + private Long accessTokenExpiresIn; + private String refreshToken; +} \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 421ba5f..9be82cd 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -12,6 +12,20 @@ spring: use_sql_comments: true hbm2ddl: auto: update +springdoc: + swagger-ui: + path: /api-docs.html + api-docs: + path: /api-docs + show-actuator: true + default-produces-media-type: application/json + +jwt: + header: Authorization + secret: ${JWT_KEY} + # token-validity-in-seconds: 86400 + token-validity-in-seconds: 1209600 # 테스트용 14일 유효기간 + cloud: aws: s3: @@ -23,9 +37,5 @@ cloud: stack: auto: false credentials: - - -springdoc: - swagger-ui: - tags-sorter: alpha - operations-sorter: alpha + accessKey: ${AWS_ACCESS_KEY} + secretKey: ${AWS_SECRET_KEY}