diff --git a/build.gradle b/build.gradle index fe546c2..d334952 100644 --- a/build.gradle +++ b/build.gradle @@ -35,6 +35,10 @@ dependencies { // spring security implementation 'org.springframework.boot:spring-boot-starter-security' + // jwt + implementation 'io.jsonwebtoken:jjwt:0.9.1' + implementation 'javax.xml.bind:jaxb-api:2.4.0-b180830.0359' + // websocket implementation 'org.springframework.boot:spring-boot-starter-websocket' diff --git a/src/main/java/com/oven/server/api/user/controller/AuthController.java b/src/main/java/com/oven/server/api/user/controller/AuthController.java index f5e5e9b..a573824 100644 --- a/src/main/java/com/oven/server/api/user/controller/AuthController.java +++ b/src/main/java/com/oven/server/api/user/controller/AuthController.java @@ -1,7 +1,13 @@ package com.oven.server.api.user.controller; +import com.oven.server.api.user.domain.User; import com.oven.server.api.user.dto.request.IdCheckRequest; +import com.oven.server.api.user.dto.request.JoinRequest; +import com.oven.server.api.user.dto.request.LoginRequest; +import com.oven.server.api.user.dto.request.RefreshTokenRequest; +import com.oven.server.api.user.dto.response.AccessTokenResponse; import com.oven.server.api.user.dto.response.IdCheckResponse; +import com.oven.server.api.user.dto.response.JwtTokenResponse; import com.oven.server.api.user.service.AuthService; import com.oven.server.common.response.Response; import com.oven.server.common.response.ResponseCode; @@ -9,6 +15,7 @@ import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -30,4 +37,32 @@ public Response idDuplicateCheck(@RequestBody IdCheckRequest id return Response.success(ResponseCode.SUCCESS_OK, idCheckResponse); } + @Operation(summary = "회원가입") + @PostMapping(value = "/join") + public Response join(@RequestBody JoinRequest joinRequest) { + authService.join(joinRequest); + return Response.success(ResponseCode.SUCCESS_CREATED); + } + + @Operation(summary = "로그인") + @PostMapping(value = "/login") + public Response login(@RequestBody LoginRequest loginRequest) { + JwtTokenResponse jwtTokenResponse = authService.login(loginRequest); + return Response.success(ResponseCode.SUCCESS_OK, jwtTokenResponse); + } + + @Operation(summary = "로그아웃") + @PostMapping(value = "/logout") + public Response signOut(@AuthenticationPrincipal User user, @RequestBody RefreshTokenRequest refreshTokenRequest) { + authService.logout(user, refreshTokenRequest); + return Response.success(ResponseCode.SUCCESS_OK); + } + + @Operation(summary = "리프레쉬 토큰으로 액세스 토큰 재발급") + @PostMapping(value = "/reissuance") + public Response reissueAccessToken(@RequestBody RefreshTokenRequest refreshTokenRequest) { + AccessTokenResponse accessTokenDto = authService.reissueAccessToken(refreshTokenRequest); + return Response.success(ResponseCode.SUCCESS_CREATED, accessTokenDto); + } + } diff --git a/src/main/java/com/oven/server/api/user/domain/RefreshToken.java b/src/main/java/com/oven/server/api/user/domain/RefreshToken.java new file mode 100644 index 0000000..13feaa4 --- /dev/null +++ b/src/main/java/com/oven/server/api/user/domain/RefreshToken.java @@ -0,0 +1,25 @@ +package com.oven.server.api.user.domain; + +import com.oven.server.common.BaseEntity; +import lombok.Builder; +import lombok.Getter; +import org.springframework.data.annotation.Id; +import org.springframework.data.redis.core.RedisHash; +import org.springframework.data.redis.core.index.Indexed; + +@Getter +@RedisHash(value = "refreshToken", timeToLive = 60 * 60 * 24 * 14) // 단위는 초 +public class RefreshToken extends BaseEntity { + + @Id + @Indexed + private String refreshToken; + private String username; + + @Builder + public RefreshToken(String refreshToken, String username) { + this.refreshToken = refreshToken; + this.username = username; + } + +} diff --git a/src/main/java/com/oven/server/api/user/domain/User.java b/src/main/java/com/oven/server/api/user/domain/User.java index e9faab6..145a93d 100644 --- a/src/main/java/com/oven/server/api/user/domain/User.java +++ b/src/main/java/com/oven/server/api/user/domain/User.java @@ -2,17 +2,18 @@ import com.oven.server.common.BaseEntity; import jakarta.persistence.*; -import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Collection; @Entity @Getter @NoArgsConstructor -@AllArgsConstructor -@Builder -public class User extends BaseEntity { +public class User extends BaseEntity implements UserDetails { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @@ -25,4 +26,37 @@ public class User extends BaseEntity { private String password; + @Builder + public User(Long id, String username, String nickname, String password) { + this.id = id; + this.username = username; + this.nickname = nickname; + this.password = password; + } + + @Override + public Collection getAuthorities() { + return null; + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return true; + } + } diff --git a/src/main/java/com/oven/server/api/user/dto/request/LoginRequest.java b/src/main/java/com/oven/server/api/user/dto/request/LoginRequest.java new file mode 100644 index 0000000..bf08042 --- /dev/null +++ b/src/main/java/com/oven/server/api/user/dto/request/LoginRequest.java @@ -0,0 +1,15 @@ +package com.oven.server.api.user.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; + +@Getter +public class LoginRequest { + + @Schema(description = "아이디", example = "id2023") + private String username; + + @Schema(description = "패스워드", example = "password123@") + private String password; + +} diff --git a/src/main/java/com/oven/server/api/user/dto/request/RefreshTokenRequest.java b/src/main/java/com/oven/server/api/user/dto/request/RefreshTokenRequest.java new file mode 100644 index 0000000..e9fe453 --- /dev/null +++ b/src/main/java/com/oven/server/api/user/dto/request/RefreshTokenRequest.java @@ -0,0 +1,12 @@ +package com.oven.server.api.user.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; + +@Getter +public class RefreshTokenRequest { + + @Schema(description = "리프레쉬 토큰", example = "asdlkjfb01sadf03918da23;091") + private String refreshToken; + +} diff --git a/src/main/java/com/oven/server/api/user/dto/response/AccessTokenResponse.java b/src/main/java/com/oven/server/api/user/dto/response/AccessTokenResponse.java new file mode 100644 index 0000000..4e304de --- /dev/null +++ b/src/main/java/com/oven/server/api/user/dto/response/AccessTokenResponse.java @@ -0,0 +1,16 @@ +package com.oven.server.api.user.dto.response; + +import lombok.Builder; +import lombok.Getter; + +@Getter +public class AccessTokenResponse { + + private String accessToken; + + @Builder + public AccessTokenResponse(String accessToken) { + this.accessToken = accessToken; + } + +} diff --git a/src/main/java/com/oven/server/api/user/repository/RefreshTokenRepository.java b/src/main/java/com/oven/server/api/user/repository/RefreshTokenRepository.java new file mode 100644 index 0000000..c2fd006 --- /dev/null +++ b/src/main/java/com/oven/server/api/user/repository/RefreshTokenRepository.java @@ -0,0 +1,8 @@ +package com.oven.server.api.user.repository; + +import com.oven.server.api.user.domain.RefreshToken; +import org.springframework.data.repository.CrudRepository; + +public interface RefreshTokenRepository extends CrudRepository { + +} diff --git a/src/main/java/com/oven/server/api/user/repository/UserRepository.java b/src/main/java/com/oven/server/api/user/repository/UserRepository.java index bc9f28f..0b5d748 100644 --- a/src/main/java/com/oven/server/api/user/repository/UserRepository.java +++ b/src/main/java/com/oven/server/api/user/repository/UserRepository.java @@ -12,4 +12,6 @@ public interface UserRepository extends JpaRepository { boolean existsByUsername(String username); + Optional findByUsername(String username); + } diff --git a/src/main/java/com/oven/server/api/user/service/AuthService.java b/src/main/java/com/oven/server/api/user/service/AuthService.java index 5440b6d..e40e685 100644 --- a/src/main/java/com/oven/server/api/user/service/AuthService.java +++ b/src/main/java/com/oven/server/api/user/service/AuthService.java @@ -1,17 +1,28 @@ package com.oven.server.api.user.service; +import com.oven.server.api.user.domain.RefreshToken; import com.oven.server.api.user.domain.User; import com.oven.server.api.user.dto.request.IdCheckRequest; import com.oven.server.api.user.dto.request.JoinRequest; +import com.oven.server.api.user.dto.request.LoginRequest; +import com.oven.server.api.user.dto.request.RefreshTokenRequest; +import com.oven.server.api.user.dto.response.AccessTokenResponse; import com.oven.server.api.user.dto.response.IdCheckResponse; import com.oven.server.api.user.dto.response.JwtTokenResponse; +import com.oven.server.api.user.repository.RefreshTokenRepository; import com.oven.server.api.user.repository.UserRepository; import com.oven.server.common.exception.BaseException; +import com.oven.server.common.response.ResponseCode; +import com.oven.server.config.jwt.JwtTokenProvider; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.Date; + @Service @Transactional @RequiredArgsConstructor @@ -19,6 +30,9 @@ public class AuthService { private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + private final JwtTokenProvider jwtTokenProvider; + private final RefreshTokenRepository refreshTokenRepository; public IdCheckResponse idDuplicateCheck(IdCheckRequest idCheckRequest) throws BaseException { @@ -28,4 +42,67 @@ public IdCheckResponse idDuplicateCheck(IdCheckRequest idCheckRequest) throws Ba } + public void join(JoinRequest joinRequest) throws BaseException { + + User newUser = User.builder() + .username(joinRequest.getUsername()) + .nickname(joinRequest.getNickname()) + .password(passwordEncoder.encode(joinRequest.getPassword())) + .build(); + + userRepository.save(newUser); + + } + + public JwtTokenResponse login(LoginRequest loginRequest) { + + User user = userRepository.findByUsername(loginRequest.getUsername()) + .orElseThrow(() -> new BaseException(ResponseCode.ID_NOT_FOUND)); + + if (!passwordEncoder.matches(loginRequest.getPassword(), user.getPassword())) { + throw new BaseException(ResponseCode.PASSWORD_NOT_MATCH); + } + + return jwtTokenProvider.createJwtToken(user.getUsername()); + } + + public void logout(User user, RefreshTokenRequest refreshTokenRequest) { + RefreshToken refreshToken = refreshTokenRepository.findById(refreshTokenRequest.getRefreshToken()).orElseThrow( + () -> new BaseException(ResponseCode.REFRESH_TOKEN_NOT_FOUND) + ); + + refreshTokenRepository.delete(refreshToken); + SecurityContextHolder.clearContext(); + + } + + public AccessTokenResponse reissueAccessToken(RefreshTokenRequest refreshTokenRequest) { + + if(!jwtTokenProvider.validateToken(refreshTokenRequest.getRefreshToken())) { + throw new BaseException(ResponseCode.TOKEN_NOT_VALID); + } + if(!refreshTokenRepository.existsById(refreshTokenRequest.getRefreshToken())) { + throw new BaseException(ResponseCode.REFRESH_TOKEN_NOT_FOUND); + } + + log.info("[reissueAccessToken] 액세스 토큰 재발급 시작"); + + RefreshToken refreshToken = refreshTokenRepository.findById(refreshTokenRequest.getRefreshToken()).orElseThrow( + () -> new BaseException(ResponseCode.REFRESH_TOKEN_NOT_FOUND) + ); + + User user = userRepository.findByUsername(refreshToken.getUsername()).orElseThrow( + () -> new BaseException(ResponseCode.USER_NOT_FOUND) + ); + + AccessTokenResponse accessTokenResponse = AccessTokenResponse.builder() + .accessToken(jwtTokenProvider.createAccessToken(user.getUsername(), new Date())) + .build(); + + log.info("[reissueAccessToken] 액세스 토큰 재발급 완료: {}", accessTokenResponse.getAccessToken()); + + return accessTokenResponse; + + } + } diff --git a/src/main/java/com/oven/server/api/user/service/RefreshTokenService.java b/src/main/java/com/oven/server/api/user/service/RefreshTokenService.java new file mode 100644 index 0000000..ead7f4a --- /dev/null +++ b/src/main/java/com/oven/server/api/user/service/RefreshTokenService.java @@ -0,0 +1,29 @@ +package com.oven.server.api.user.service; + +import com.oven.server.api.user.domain.RefreshToken; +import com.oven.server.api.user.repository.RefreshTokenRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +@RequiredArgsConstructor +@Slf4j +public class RefreshTokenService { + + private final RefreshTokenRepository refreshTokenRepository; + + public void saveRefreshToken(String token, String username) { + RefreshToken refreshToken = RefreshToken.builder() + .refreshToken(token) + .username(username) + .build(); + + refreshTokenRepository.save(refreshToken); + + log.info("[로그인 후 Refresh Token 저장]"); + } + +} diff --git a/src/main/java/com/oven/server/api/user/service/UserDetailsServiceImpl.java b/src/main/java/com/oven/server/api/user/service/UserDetailsServiceImpl.java new file mode 100644 index 0000000..edf3cfc --- /dev/null +++ b/src/main/java/com/oven/server/api/user/service/UserDetailsServiceImpl.java @@ -0,0 +1,30 @@ +package com.oven.server.api.user.service; + +import com.oven.server.api.user.domain.User; +import com.oven.server.api.user.repository.UserRepository; +import com.oven.server.common.exception.BaseException; +import com.oven.server.common.response.ResponseCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +@Slf4j +public class UserDetailsServiceImpl implements UserDetailsService { + + private final UserRepository userRepository; + + @Override + public UserDetails loadUserByUsername(String username) throws BaseException { + log.info("[UserDetailsServiceImpl] loadUserByUsername -> username: {}", username); + + return userRepository.findByUsername(username) + .orElseThrow(() -> new BaseException(ResponseCode.USER_NOT_FOUND)); + + } +} diff --git a/src/main/java/com/oven/server/api/work/dto/request/PostRatingDto.java b/src/main/java/com/oven/server/api/work/dto/request/PostRatingDto.java index 95aa382..4611286 100644 --- a/src/main/java/com/oven/server/api/work/dto/request/PostRatingDto.java +++ b/src/main/java/com/oven/server/api/work/dto/request/PostRatingDto.java @@ -6,11 +6,9 @@ import lombok.Getter; @Getter -@Builder -@AllArgsConstructor public class PostRatingDto { @Schema(description = "평점", example = "3") - private int rating; + private Integer rating; } diff --git a/src/main/java/com/oven/server/common/response/ResponseCode.java b/src/main/java/com/oven/server/common/response/ResponseCode.java index f49fa98..531c05e 100644 --- a/src/main/java/com/oven/server/common/response/ResponseCode.java +++ b/src/main/java/com/oven/server/common/response/ResponseCode.java @@ -27,6 +27,8 @@ public enum ResponseCode { ACCESS_DENIED(UNAUTHORIZED, "접근이 금지되었습니다."), DUPLICATE_NICKNAME(BAD_REQUEST, "중복된 사용자 계정명입니다."), REFRESH_TOKEN_NOT_FOUND(UNAUTHORIZED, "리프레쉬 토큰이 만료되었습니다"), + ID_NOT_FOUND(BAD_REQUEST, "존재하지 않는 아이디입니다."), + PASSWORD_NOT_MATCH(BAD_REQUEST, "비밀번호가 일치하지 않습니다."), // Work WORK_NOT_FOUND(BAD_REQUEST, "작품을 찾을 수 없습니다."); diff --git a/src/main/java/com/oven/server/config/RedisConfig.java b/src/main/java/com/oven/server/config/RedisConfig.java new file mode 100644 index 0000000..c532076 --- /dev/null +++ b/src/main/java/com/oven/server/config/RedisConfig.java @@ -0,0 +1,41 @@ +package com.oven.server.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.repository.configuration.EnableRedisRepositories; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +@EnableRedisRepositories +public class RedisConfig { + + @Value("${spring.data.redis.host}") + private String host; + + @Value("${spring.data.redis.port}") + private int port; + + @Bean + public RedisConnectionFactory redisConnectionFactory() { + return new LettuceConnectionFactory(host, port); + } + + @Bean + public RedisTemplate redisTemplate() { + // redisTemplate를 받아와서 set, get, delete를 사용 + RedisTemplate redisTemplate = new RedisTemplate<>(); + /** + * setKeySerializer, setValueSerializer 설정 + * redis-cli을 통해 직접 데이터를 조회 시 알아볼 수 없는 형태로 출력되는 것을 방지 + */ + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new StringRedisSerializer()); + redisTemplate.setConnectionFactory(redisConnectionFactory()); + return redisTemplate; + } + +} diff --git a/src/main/java/com/oven/server/config/SecurityConfig.java b/src/main/java/com/oven/server/config/SecurityConfig.java index 3977d9e..56c462e 100644 --- a/src/main/java/com/oven/server/config/SecurityConfig.java +++ b/src/main/java/com/oven/server/config/SecurityConfig.java @@ -1,24 +1,87 @@ package com.oven.server.config; +import com.oven.server.config.jwt.JwtAccessDeniedHandler; +import com.oven.server.config.jwt.JwtAuthenticationEntryPoint; +import com.oven.server.config.jwt.JwtAuthenticationFilter; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.List; @RequiredArgsConstructor @Configuration @EnableWebSecurity public class SecurityConfig { + private final JwtAuthenticationFilter jwtAuthenticationFilter; + private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; + private final JwtAccessDeniedHandler jwtAccessDeniedHandler; + + @Bean + public BCryptPasswordEncoder bCryptPasswordEncoder() { + return new BCryptPasswordEncoder(); + } + + private static final String[] swagger = { + "/v3/api-docs/**", + "/swagger-resources/**", + "/swagger-ui/**", + "/swagger/**", + "/webjars/**" + }; + @Bean public SecurityFilterChain configure(HttpSecurity httpSecurity) throws Exception { return httpSecurity + .cors().configurationSource(corsConfigurationSource()) + .and() + .httpBasic().disable() .csrf().disable() + .sessionManagement() + .sessionCreationPolicy(SessionCreationPolicy.STATELESS) + + .and() + .authorizeHttpRequests() + .requestMatchers(swagger).permitAll() + .requestMatchers(HttpMethod.POST, "/auth/**").permitAll() + .anyRequest().authenticated() + + .and() + .exceptionHandling() + .authenticationEntryPoint(jwtAuthenticationEntryPoint) + .accessDeniedHandler(jwtAccessDeniedHandler) + + .and() + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) .build(); } + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration config = new CorsConfiguration(); + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + + config.setAllowCredentials(true); + config.addAllowedOriginPattern("*"); + config.addAllowedHeader("*"); + config.addAllowedMethod("*"); + config.setExposedHeaders(List.of("*")); + + source.registerCorsConfiguration("/**", config); + return source; + } + } diff --git a/src/main/java/com/oven/server/config/SwaggerConfig.java b/src/main/java/com/oven/server/config/SwaggerConfig.java index d0eb46f..9639562 100644 --- a/src/main/java/com/oven/server/config/SwaggerConfig.java +++ b/src/main/java/com/oven/server/config/SwaggerConfig.java @@ -3,8 +3,17 @@ import io.swagger.v3.oas.annotations.OpenAPIDefinition; import io.swagger.v3.oas.annotations.info.Info; import io.swagger.v3.oas.annotations.servers.Server; +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import java.util.Arrays; + +// https://wonsjung.tistory.com/584 + @OpenAPIDefinition( info = @Info(title = "Oven 2023 API Document", description = "2023 홍익대학교 컴퓨터공학과 졸업 프로젝트 Oven API 명세서", @@ -13,4 +22,17 @@ ) @Configuration public class SwaggerConfig { + + @Bean + 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().addSecuritySchemes("bearerAuth", securityScheme)) + .security(Arrays.asList(securityRequirement)); + } + } diff --git a/src/main/java/com/oven/server/config/jwt/JwtAccessDeniedHandler.java b/src/main/java/com/oven/server/config/jwt/JwtAccessDeniedHandler.java new file mode 100644 index 0000000..266d7ab --- /dev/null +++ b/src/main/java/com/oven/server/config/jwt/JwtAccessDeniedHandler.java @@ -0,0 +1,30 @@ +package com.oven.server.config.jwt; + +import com.oven.server.common.response.ResponseCode; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +import static com.oven.server.common.response.Response.setJsonExceptionResponse; + +@Slf4j +@Component +public class JwtAccessDeniedHandler implements AccessDeniedHandler { + + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, + AccessDeniedException accessDeniedException) throws IOException { + + log.info("[handle] 접근이 막혔을 경우 에러 throw"); + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType("application/json"); + response.setCharacterEncoding("utf-8"); + response.getWriter().print(setJsonExceptionResponse(ResponseCode.ACCESS_DENIED)); + } + +} diff --git a/src/main/java/com/oven/server/config/jwt/JwtAuthenticationEntryPoint.java b/src/main/java/com/oven/server/config/jwt/JwtAuthenticationEntryPoint.java new file mode 100644 index 0000000..f468711 --- /dev/null +++ b/src/main/java/com/oven/server/config/jwt/JwtAuthenticationEntryPoint.java @@ -0,0 +1,31 @@ +package com.oven.server.config.jwt; + +import com.oven.server.common.response.ResponseCode; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +import static com.oven.server.common.response.Response.setJsonExceptionResponse; + +@Slf4j +@Component +public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { + + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException { + + log.info("[commence] 인증 실패로 response.sendError 발생"); + + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType("application/json"); + response.setCharacterEncoding("utf-8"); + response.getWriter().print(setJsonExceptionResponse(ResponseCode.USER_NOT_FOUND)); + + } + +} diff --git a/src/main/java/com/oven/server/config/jwt/JwtAuthenticationFilter.java b/src/main/java/com/oven/server/config/jwt/JwtAuthenticationFilter.java new file mode 100644 index 0000000..23dfce7 --- /dev/null +++ b/src/main/java/com/oven/server/config/jwt/JwtAuthenticationFilter.java @@ -0,0 +1,49 @@ +package com.oven.server.config.jwt; + +import com.oven.server.common.exception.BaseException; +import com.oven.server.common.response.ResponseCode; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +import static com.oven.server.config.jwt.JwtExceptionHandler.handle; + +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtTokenProvider jwtTokenProvider; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + + String token = jwtTokenProvider.resolveToken(request); + log.info("[doFilterInternal] token 값 추출 완료: token : {}", token); + + log.info("[doFilterInternal] token 값 유효성 체크 시작"); + + try { + if (token != null && jwtTokenProvider.validateToken(token)) { + Authentication authentication = jwtTokenProvider.getAuthentication(token); + SecurityContextHolder.getContext().setAuthentication(authentication); + log.info("[doFilterInternal] token 값 유효성 체크 완료"); + } + } catch (BaseException e) { + log.info("[doFilterInternal] token 값 유효성 체크 실패"); + handle(response, ResponseCode.TOKEN_NOT_VALID); + } + + filterChain.doFilter(request, response); + } +} diff --git a/src/main/java/com/oven/server/config/jwt/JwtExceptionHandler.java b/src/main/java/com/oven/server/config/jwt/JwtExceptionHandler.java new file mode 100644 index 0000000..5c03e80 --- /dev/null +++ b/src/main/java/com/oven/server/config/jwt/JwtExceptionHandler.java @@ -0,0 +1,21 @@ +package com.oven.server.config.jwt; + +import com.oven.server.common.exception.BaseException; +import com.oven.server.common.response.ResponseCode; +import jakarta.servlet.http.HttpServletResponse; + +import java.io.IOException; + +import static com.oven.server.common.response.Response.setJsonExceptionResponse; + +public class JwtExceptionHandler { + + public static void handle(HttpServletResponse response, ResponseCode exception) throws BaseException, IOException { + + response.setContentType("application/json;charset=UTF-8"); + response.setContentType("application/json"); + response.setStatus(exception.getHttpStatus().value()); + response.getWriter().print(setJsonExceptionResponse(exception)); + + } +} diff --git a/src/main/java/com/oven/server/config/jwt/JwtTokenProvider.java b/src/main/java/com/oven/server/config/jwt/JwtTokenProvider.java new file mode 100644 index 0000000..7ea0d17 --- /dev/null +++ b/src/main/java/com/oven/server/config/jwt/JwtTokenProvider.java @@ -0,0 +1,140 @@ +package com.oven.server.config.jwt; + +import com.oven.server.api.user.dto.response.JwtTokenResponse; +import com.oven.server.api.user.service.RefreshTokenService; +import com.oven.server.common.exception.BaseException; +import com.oven.server.common.response.ResponseCode; +import io.jsonwebtoken.*; +import jakarta.annotation.PostConstruct; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +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.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.stereotype.Component; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Date; + +@Component +@RequiredArgsConstructor +@Slf4j +public class JwtTokenProvider { + + private final UserDetailsService userDetailsService; + private final RefreshTokenService refreshTokenService; + + @Value("${jwt.secret}") + private String secretKey; + private final long tokenValidTime = 1000L * 60 * 60; // 액세스 토큰 유효 시간 60분 + private final long refreshValidTime = 1000L * 60 * 60 * 24 * 14; // 리프레쉬 토큰 유효 시간 2주 + + @PostConstruct + protected void init() { + log.info("[init] JwtTokenProvider 내 secretKey 초기화 시작"); + secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes(StandardCharsets.UTF_8)); + log.info("[init] JwtTokenProvider 내 secretKey 초기화 완료"); + } + + public JwtTokenResponse createJwtToken(String username) { + log.info("[createJwtToken] Jwt 토큰 생성 시작"); + + final Date now = new Date(); + log.info("Date 생성 Date : {} ", now); + + JwtTokenResponse jwtTokenResponse = JwtTokenResponse.builder() + .accessToken(createAccessToken(username, now)) + .refreshToken(createRefreshToken(username, now)) + .build(); + + log.info("[createJwtToken] Jwt 토큰 생성 완료"); + + + return jwtTokenResponse; + } + + public String createAccessToken(String username, Date now) { + log.info("[createAccessToken] 액세스 토큰 생성 시작"); + + Claims claims = Jwts.claims().setSubject(username); + log.info("Access Token에서 claims 생성 claims : {}", claims); + + String accessToken = Jwts.builder() + .setClaims(claims) + .setIssuer("Oven") + .setIssuedAt(now) + .setExpiration(new Date(now.getTime() + tokenValidTime)) + .signWith(SignatureAlgorithm.HS256, secretKey) + .compact(); + + log.info("[createToken] 액세스 토큰 생성 완료"); + + return accessToken; + } + + public String createRefreshToken(String username, Date now) { + log.info("[createRefreshToken] 리프레쉬 토큰 생성 시작"); + + String refreshToken = Jwts.builder() + .setHeaderParam(Header.TYPE, Header.JWT_TYPE) + .setIssuer("Oven") + .setExpiration(new Date(now.getTime() + refreshValidTime)) + .signWith(SignatureAlgorithm.HS256, secretKey) + .compact(); + + refreshTokenService.saveRefreshToken(refreshToken, username); + + log.info("[createRefreshToken] 리프레쉬 토큰 생성 완료"); + + return refreshToken; + } + + public Authentication getAuthentication(String token) { + log.info("[getAuthentication] 토큰 인증 정보 조회 시작"); + UserDetails userDetails = userDetailsService.loadUserByUsername(this.getUserEmail(token)); + + log.info("[getAuthentication] 토큰 인증 정보 조회 완료, UserDetails User Email : {}", userDetails.getUsername()); + + return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities()); + } + + private String getUserEmail(String token) { + log.info("[getUserEmail] 토큰 기반 회원 구별 정보 추출"); + + String username = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody().getSubject(); + log.info("[getUserEmail] 토큰 기반 회원 구별 정보 추출 완료, username : {}", username); + + return username; + } + + public String resolveToken(HttpServletRequest request) { + log.info("[resolveToken] HTTP 헤더에서 Token 값 추출"); + + String authorization = request.getHeader("Authorization"); + + if(authorization != null) { + String token = authorization.split(" ")[1].trim(); + log.info("[resolveToken] HTTP 헤더에서 Token 값 추출 완료: {}", token); + return token; + } + + return null; + } + + public boolean validateToken(String token) { + log.info("[validateToken] 토큰 유효 체크 시작"); + + try { + Jws claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token); + return !claims.getBody().getExpiration().before(new Date()); + } catch (BaseException e) { + throw new BaseException(ResponseCode.TOKEN_NOT_VALID); + } + + } + +}