From b10f99c7654c191bece436f7317f8ef457d741f0 Mon Sep 17 00:00:00 2001 From: kimtaeyoung <62414583+kimtaeyoung201910794@users.noreply.github.com> Date: Wed, 26 Jun 2024 18:36:07 +0900 Subject: [PATCH] =?UTF-8?q?:sparkles:=20Feat:=20JWT=20=EC=9D=B8=EC=A6=9D?= =?UTF-8?q?=20=EC=9D=B8=EA=B0=80=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 3 + .../apiPayload/code/status/ErrorStatus.java | 15 +- .../exception/handler/AuthHandler.java | 10 + .../umc/study/config/SecurityConfig.java | 179 ++++++++++++++---- .../config/filter/JWTAccessDeniedHandler.java | 37 ++++ .../filter/JWTAuthenticationEntryPoint.java | 37 ++++ .../config/filter/JWTExceptionFilter.java | 37 ++++ .../umc/study/config/filter/JWTFilter.java | 49 +++++ .../umc/study/config/filter/LoginFilter.java | 106 +++++++++++ .../umc/study/config/filter/TestFilter.java | 4 + .../umc/study/dto/LoginRequestDTO.java | 10 + .../com/example/umc/study/jwt/JWTUtil.java | 87 +++++++++ 12 files changed, 529 insertions(+), 45 deletions(-) create mode 100644 src/main/java/com/example/umc/study/apiPayload/exception/handler/AuthHandler.java create mode 100644 src/main/java/com/example/umc/study/config/filter/JWTAccessDeniedHandler.java create mode 100644 src/main/java/com/example/umc/study/config/filter/JWTAuthenticationEntryPoint.java create mode 100644 src/main/java/com/example/umc/study/config/filter/JWTExceptionFilter.java create mode 100644 src/main/java/com/example/umc/study/config/filter/JWTFilter.java create mode 100644 src/main/java/com/example/umc/study/config/filter/LoginFilter.java create mode 100644 src/main/java/com/example/umc/study/dto/LoginRequestDTO.java create mode 100644 src/main/java/com/example/umc/study/jwt/JWTUtil.java diff --git a/build.gradle b/build.gradle index b70cb33..7db7b00 100644 --- a/build.gradle +++ b/build.gradle @@ -33,6 +33,9 @@ dependencies { implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0' implementation 'org.springframework.boot:spring-boot-starter-security' testImplementation 'org.springframework.security:spring-security-test' + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' } tasks.named('test') { diff --git a/src/main/java/com/example/umc/study/apiPayload/code/status/ErrorStatus.java b/src/main/java/com/example/umc/study/apiPayload/code/status/ErrorStatus.java index 404ab4d..32564c8 100644 --- a/src/main/java/com/example/umc/study/apiPayload/code/status/ErrorStatus.java +++ b/src/main/java/com/example/umc/study/apiPayload/code/status/ErrorStatus.java @@ -20,11 +20,18 @@ public enum ErrorStatus implements BaseErrorCode { //post에러 _NOT_FOUND_POST(HttpStatus.NOT_FOUND, "POST400", "게시글을 찾을 수 없습니다"), - //reply에러 - _NOT_FOUND_REPLY(HttpStatus.NOT_FOUND, "REPLY400", "댓글을 찾을 수 없습니다"), - //테스트 에러 - _TEMP_EXCEPTION(HttpStatus.BAD_REQUEST, "TEMP4001", "이것은 테스트"); + _TEMP_EXCEPTION(HttpStatus.BAD_REQUEST, "TEMP4001", "이것은 테스트"), + + // Reply 에러 + _NOT_FOUND_REPLY(HttpStatus.NOT_FOUND, "REPLY400", "reply를 찾을 수 없습니다."), + + // auth 관련 에러 + _TOKEN_NOT_FOUND(HttpStatus.NOT_FOUND, "AUTH400", "토큰을 찾을 수 없습니다."), + _AUTH_EXPIRE_TOKEN(HttpStatus.UNAUTHORIZED, "AUTH401", "토큰이 만료되었습니다."), + _AUTH_INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "AUTH402", "토큰이 유효하지 않습니다."), + + _AUTHENTICATION_FAILED(HttpStatus.UNAUTHORIZED, "AUTH403", "인증 실패"); private final HttpStatus httpStatus; private final String code; diff --git a/src/main/java/com/example/umc/study/apiPayload/exception/handler/AuthHandler.java b/src/main/java/com/example/umc/study/apiPayload/exception/handler/AuthHandler.java new file mode 100644 index 0000000..8197206 --- /dev/null +++ b/src/main/java/com/example/umc/study/apiPayload/exception/handler/AuthHandler.java @@ -0,0 +1,10 @@ +package com.example.umc.study.apiPayload.exception.handler; + +import com.example.umc.study.apiPayload.code.BaseErrorCode; +import com.example.umc.study.apiPayload.exception.GeneralException; + +public class AuthHandler extends GeneralException { + public AuthHandler(BaseErrorCode code) { + super(code); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/umc/study/config/SecurityConfig.java b/src/main/java/com/example/umc/study/config/SecurityConfig.java index 252379f..71b782f 100644 --- a/src/main/java/com/example/umc/study/config/SecurityConfig.java +++ b/src/main/java/com/example/umc/study/config/SecurityConfig.java @@ -1,78 +1,175 @@ package com.example.umc.study.config; +//import com.example.umc.study.config.filter.TestFilter; +//import com.example.umc.study.config.filter.TestFilter2; +//import org.springframework.context.annotation.Bean; +//import org.springframework.context.annotation.Configuration; +//import org.springframework.http.HttpMethod; +//import org.springframework.security.config.Customizer; +//import org.springframework.security.config.annotation.web.builders.HttpSecurity; +//import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +//import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +//import org.springframework.security.config.http.SessionCreationPolicy; +//import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +//import org.springframework.security.crypto.password.PasswordEncoder; +//import org.springframework.security.web.SecurityFilterChain; +//import org.springframework.security.web.authentication.www.BasicAuthenticationFilter; +// +//@Configuration +//@EnableWebSecurity(debug = true) +//public class SecurityConfig { +// +// private final String[] allowUrl = { +// "/swagger-ui/**", +// "/swagger-resources/**", +// "/v3/api-docs/**", +// "/api/v1/posts/**", +// "/api/v1/replies/**", +// "/test" +// }; +// +// +//// @Bean +//// SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { +//// +//// http.authorizeHttpRequests((requests) -> requests +//// .requestMatchers("/swagger-ui/**").permitAll() +//// .anyRequest().authenticated()); +//// return http.build(); +//// } +// +// @Bean +// SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception { +// http.csrf(AbstractHttpConfigurer::disable); +// http.authorizeHttpRequests((requests) -> requests +// +//// .requestMatchers("/api/v1/replies/**").permitAll() //api/v1/replies/**은 /api/v1/replies가 앞으로 붙어있는 모든 주소에 대해 허용 +//// .requestMatchers("api/v1/users/**").hasAnyAuthority("admin") +//// .requestMatchers("/api/v1/posts/**").permitAll()//api/v1/users/**은 /api/v1/users가 앞으로 붙어있는 모든 주소에 대해 admin 역할을 가진 user에게 허용 +// .requestMatchers(HttpMethod.POST,"/api/v1/users").permitAll() +//// .requestMatchers(HttpMethod.POST,"/api/v1/users/{userId}/posts").hasAnyRole("USER", "ADMIN") +//// .requestMatchers(HttpMethod.POST,"/api/v1/replies").hasRole("ADMIN") +// .requestMatchers(HttpMethod.POST, "/api/v1/users/{userId}/posts").hasAnyRole("ADMIN", "USER") +// .requestMatchers(HttpMethod.PATCH, "/api/v1/posts/{postId}").hasAnyRole("ADMIN","USER") +// .requestMatchers(HttpMethod.POST, "/users/{userId}/posts/{postId}/replies/").hasRole("ADMIN") +// .requestMatchers(allowUrl).permitAll() +// .anyRequest().authenticated()); // anyRequest.authenticated는 나머지 모든 request에 대해 인증 +// http.formLogin(AbstractHttpConfigurer::disable); +// http.httpBasic(AbstractHttpConfigurer::disable); +// http. +// sessionManagement((session) -> session +// .sessionCreationPolicy(SessionCreationPolicy.STATELESS)); +// //http.addFilterAfter(new TestFilter(), BasicAuthenticationFilter.class); +// http.addFilterAfter(new TestFilter2(), BasicAuthenticationFilter.class); +// return http.build(); +// } +// +// @Bean +// public PasswordEncoder passwordEncoder() { +// return new BCryptPasswordEncoder(); +// } +// +//// @Bean +//// SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { +//// http.csrf(AbstractHttpConfigurer::disable); +//// http.authorizeHttpRequests((requests) -> requests +//// .anyRequest().authenticated() +//// ); +//// return http.build(); +//// } +// +// +//} + + import com.example.umc.study.config.filter.TestFilter; import com.example.umc.study.config.filter.TestFilter2; +import com.example.umc.study.config.filter.*; +import com.example.umc.study.config.jwt.JWTUtil; +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.Customizer; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.AnonymousAuthenticationFilter; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.authentication.www.BasicAuthenticationFilter; +import static org.springframework.security.config.Customizer.withDefaults; + @Configuration @EnableWebSecurity(debug = true) +@RequiredArgsConstructor public class SecurityConfig { + private final AuthenticationConfiguration authenticationConfiguration; + private final JWTUtil jwtUtil; + private final PrincipalDetailService principalDetailsService; + private final JWTAccessDeniedHandler jwtAccessDeniedHandler; + private final JWTAuthenticationEntryPoint jwtAuthenticationEntryPoint; + private final String[] allowUrl = { "/swagger-ui/**", "/swagger-resources/**", "/v3/api-docs/**", "/api/v1/posts/**", - "/api/v1/replies/**", - "/test" + "api/v1/replies/**" }; - - -// @Bean -// SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { -// -// http.authorizeHttpRequests((requests) -> requests -// .requestMatchers("/swagger-ui/**").permitAll() -// .anyRequest().authenticated()); -// return http.build(); -// } - @Bean SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception { http.csrf(AbstractHttpConfigurer::disable); http.authorizeHttpRequests((requests) -> requests - -// .requestMatchers("/api/v1/replies/**").permitAll() //api/v1/replies/**은 /api/v1/replies가 앞으로 붙어있는 모든 주소에 대해 허용 -// .requestMatchers("api/v1/users/**").hasAnyAuthority("admin") -// .requestMatchers("/api/v1/posts/**").permitAll()//api/v1/users/**은 /api/v1/users가 앞으로 붙어있는 모든 주소에 대해 admin 역할을 가진 user에게 허용 - .requestMatchers(HttpMethod.POST,"/api/v1/users").permitAll() -// .requestMatchers(HttpMethod.POST,"/api/v1/users/{userId}/posts").hasAnyRole("USER", "ADMIN") -// .requestMatchers(HttpMethod.POST,"/api/v1/replies").hasRole("ADMIN") - .requestMatchers(HttpMethod.POST, "/api/v1/users/{userId}/posts").hasAnyRole("ADMIN", "USER") - .requestMatchers(HttpMethod.PATCH, "/api/v1/posts/{postId}").hasAnyRole("ADMIN","USER") - .requestMatchers(HttpMethod.POST, "/users/{userId}/posts/{postId}/replies/").hasRole("ADMIN") + .requestMatchers(HttpMethod.POST, "/api/v1/users").permitAll() + .requestMatchers(HttpMethod.POST, "/api/v1/users/{userId}/posts").hasAnyRole("USER", "ADMIN") + .requestMatchers(HttpMethod.PATCH, "/api/v1//posts/{postId}").hasAnyRole("USER", "ADMIN") + .requestMatchers(HttpMethod.POST, "/api/v1/users/{userId}/posts/{postId}/replies").hasAnyRole("ADMIN") + .requestMatchers(HttpMethod.PATCH, "/api/v1/replies/{replyId}").hasAnyRole("ADMIN") .requestMatchers(allowUrl).permitAll() - .anyRequest().authenticated()); // anyRequest.authenticated는 나머지 모든 request에 대해 인증 - http.formLogin(Customizer.withDefaults()); - http.httpBasic(Customizer.withDefaults()); - //http.addFilterAfter(new TestFilter(), BasicAuthenticationFilter.class); - http.addFilterAfter(new TestFilter2(), BasicAuthenticationFilter.class); + .anyRequest().authenticated()); + /*http.addFilterAfter(new TestFilter(), BasicAuthenticationFilter.class);*/ + http.addFilterAfter(new TestFilter2(), AnonymousAuthenticationFilter.class); + http.formLogin(withDefaults()); + http.httpBasic(withDefaults()); + /*http.addFilterAfter(new TestFilter2(), AnonymousAuthenticationFilter.class);*/ + http.exceptionHandling( + (configurer -> + configurer + .authenticationEntryPoint(jwtAuthenticationEntryPoint) + .accessDeniedHandler(jwtAccessDeniedHandler) + ) + ); + + http.addFilterAt(new LoginFilter(authenticationManager(authenticationConfiguration), jwtUtil), + UsernamePasswordAuthenticationFilter.class); + http.addFilterBefore(new JWTFilter(jwtUtil, principalDetailsService), + UsernamePasswordAuthenticationFilter.class); + http.addFilterBefore(new JWTExceptionFilter(), JWTFilter.class); + // form 로그인 방식 disable + http.formLogin(AbstractHttpConfigurer::disable); + // http basic 인증 방식 disable + http.httpBasic(AbstractHttpConfigurer::disable); + // Session Stateless하게 관리 + http.sessionManagement((session) -> session + .sessionCreationPolicy(SessionCreationPolicy.STATELESS) + ); return http.build(); } + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception { + return configuration.getAuthenticationManager(); + } + @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } - -// @Bean -// SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { -// http.csrf(AbstractHttpConfigurer::disable); -// http.authorizeHttpRequests((requests) -> requests -// .anyRequest().authenticated() -// ); -// return http.build(); -// } - - -} +} \ No newline at end of file diff --git a/src/main/java/com/example/umc/study/config/filter/JWTAccessDeniedHandler.java b/src/main/java/com/example/umc/study/config/filter/JWTAccessDeniedHandler.java new file mode 100644 index 0000000..befce33 --- /dev/null +++ b/src/main/java/com/example/umc/study/config/filter/JWTAccessDeniedHandler.java @@ -0,0 +1,37 @@ +package com.example.umc.study.config.filter; + + +import com.example.umc.study.apiPayload.BaseResponse; +import com.example.umc.study.apiPayload.code.status.ErrorStatus; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +public class JWTAccessDeniedHandler implements AccessDeniedHandler { + + @Override + public void handle( + HttpServletRequest request, + HttpServletResponse response, + AccessDeniedException accessDeniedException) + throws IOException, ServletException { + response.setContentType("application/json; charset=UTF-8"); + response.setStatus(403); + + BaseResponse errorResponse = + BaseResponse.onFailure( + ErrorStatus._FORBIDDEN.getCode(), + ErrorStatus._FORBIDDEN.getMessage(), + null); + + ObjectMapper mapper = new ObjectMapper(); + mapper.writeValue(response.getOutputStream(), errorResponse); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/umc/study/config/filter/JWTAuthenticationEntryPoint.java b/src/main/java/com/example/umc/study/config/filter/JWTAuthenticationEntryPoint.java new file mode 100644 index 0000000..8b626e1 --- /dev/null +++ b/src/main/java/com/example/umc/study/config/filter/JWTAuthenticationEntryPoint.java @@ -0,0 +1,37 @@ +package com.example.umc.study.config.filter; + + +import com.example.umc.study.apiPayload.BaseResponse; +import com.example.umc.study.apiPayload.code.status.ErrorStatus; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +public class JWTAuthenticationEntryPoint implements AuthenticationEntryPoint { + + @Override + public void commence( + HttpServletRequest request, + HttpServletResponse response, + AuthenticationException authException) + throws IOException, ServletException { + response.setContentType("application/json; charset=UTF-8"); + response.setStatus(401); + + BaseResponse errorResponse = + BaseResponse.onFailure( + ErrorStatus._UNAUTHORIZED.getCode(), + ErrorStatus._UNAUTHORIZED.getMessage(), + null); + + ObjectMapper mapper = new ObjectMapper(); + mapper.writeValue(response.getOutputStream(), errorResponse); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/umc/study/config/filter/JWTExceptionFilter.java b/src/main/java/com/example/umc/study/config/filter/JWTExceptionFilter.java new file mode 100644 index 0000000..cbfd066 --- /dev/null +++ b/src/main/java/com/example/umc/study/config/filter/JWTExceptionFilter.java @@ -0,0 +1,37 @@ +package com.example.umc.study.config.filter; + +import com.example.umc.study.apiPayload.BaseResponse; +import com.example.umc.study.apiPayload.code.status.ErrorStatus; +import com.example.umc.study.apiPayload.exception.handler.AuthHandler; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +public class JWTExceptionFilter extends OncePerRequestFilter { + + @Override + protected void doFilterInternal( + HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + try { + filterChain.doFilter(request, response); + } catch (AuthHandler e) { + response.setContentType("application/json; charset=UTF-8"); + response.setStatus(e.getErrorReasonHttpStatus().getHttpStatus().value()); + + ErrorStatus code = (ErrorStatus) e.getCode(); + + BaseResponse errorResponse = + BaseResponse.onFailure(code.getCode(), code.getMessage(), null); + + ObjectMapper mapper = new ObjectMapper(); + mapper.writeValue(response.getOutputStream(), errorResponse); + } + + } +} \ No newline at end of file diff --git a/src/main/java/com/example/umc/study/config/filter/JWTFilter.java b/src/main/java/com/example/umc/study/config/filter/JWTFilter.java new file mode 100644 index 0000000..c55d00b --- /dev/null +++ b/src/main/java/com/example/umc/study/config/filter/JWTFilter.java @@ -0,0 +1,49 @@ +package com.example.umc.study.config.filter; + + +import com.example.umc.study.apiPayload.code.status.ErrorStatus; +import com.example.umc.study.apiPayload.exception.handler.AuthHandler; +import com.example.umc.study.config.PrincipalDetailService; +import com.example.umc.study.config.jwt.JWTUtil; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@RequiredArgsConstructor +public class JWTFilter extends OncePerRequestFilter { + + private final JWTUtil jwtUtil; + private final PrincipalDetailService principalDetailsService; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + String authorizationHeader = request.getHeader("Authorization"); + + if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) { + String token = authorizationHeader.substring(7); + + if(jwtUtil.isTokenValid(token)) { + String email = jwtUtil.getEmail(token); + UserDetails userDetails = principalDetailsService.loadUserByUsername(email); + + if (userDetails != null) { + UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = + new UsernamePasswordAuthenticationToken( + userDetails, "", userDetails.getAuthorities()); + SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken); + } else { + throw new AuthHandler(ErrorStatus._NOT_FOUND_USER); + } + } + } + filterChain.doFilter(request, response); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/umc/study/config/filter/LoginFilter.java b/src/main/java/com/example/umc/study/config/filter/LoginFilter.java new file mode 100644 index 0000000..d6e5b74 --- /dev/null +++ b/src/main/java/com/example/umc/study/config/filter/LoginFilter.java @@ -0,0 +1,106 @@ +package com.example.umc.study.config.filter; + +import com.example.umc.study.apiPayload.BaseResponse; +import com.example.umc.study.apiPayload.code.status.ErrorStatus; +import com.example.umc.study.apiPayload.exception.handler.AuthHandler; +import com.example.umc.study.config.PrincipalDetails; +import com.example.umc.study.config.jwt.JWTUtil; +import com.example.umc.study.dto.LoginRequestDTO; +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 lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +import java.io.IOException; +import java.util.Collection; +import java.util.Iterator; + +@Slf4j +@RequiredArgsConstructor +public class LoginFilter extends UsernamePasswordAuthenticationFilter { + + private final AuthenticationManager authenticationManager; + private final JWTUtil jwtUtil; + + // /login 요청을 하면, 로그인 시도를 위해서 실행되는 함수 + @Override + public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { + LoginRequestDTO loginRequestDTO = readBody(request); + + UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = UsernamePasswordAuthenticationToken + .unauthenticated(loginRequestDTO.getEmail(), loginRequestDTO.getPassword()); + + return authenticationManager.authenticate(usernamePasswordAuthenticationToken); + } + + private LoginRequestDTO readBody(HttpServletRequest request) { + LoginRequestDTO loginRequestDTO = null; + ObjectMapper om = new ObjectMapper(); + + try { + loginRequestDTO = om.readValue(request.getInputStream(), LoginRequestDTO.class); + } catch (IOException e) { + throw new AuthHandler(ErrorStatus._BAD_REQUEST); + } + + return loginRequestDTO; + } + + + // JWT Token 생성해서 response에 담아주기 + @Override + protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, + Authentication authResult) throws IOException{ + + // Authentication에서 정보 가져오기 + PrincipalDetails principalDetails = (PrincipalDetails) authResult.getPrincipal(); + + // 정보 받아오기 + String email = principalDetails.getUsername(); + Collection authorities = authResult.getAuthorities(); + Iterator iterator = authorities.iterator(); + GrantedAuthority auth = iterator.next(); + + String role = auth.getAuthority(); + + // 토큰 만들기 + String token = jwtUtil.createAccessToken(email, role); + + // 헤더에 추가하기 + response.addHeader("Authorization", "Bearer " + token); + + // 성공 응답 통일 + response.setContentType("application/json; charset=UTF-8"); + response.setStatus(HttpStatus.OK.value()); + + BaseResponse errorResponse = + BaseResponse.onSuccess(null); + + ObjectMapper mapper = new ObjectMapper(); + mapper.writeValue(response.getOutputStream(), errorResponse); + } + + @Override + protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException { + response.setContentType("application/json; charset=UTF-8"); + response.setStatus(HttpStatus.UNAUTHORIZED.value()); + ErrorStatus UN_AUTH = ErrorStatus._AUTHENTICATION_FAILED; + + BaseResponse errorResponse = + BaseResponse.onFailure(UN_AUTH.getCode(), UN_AUTH.getMessage(), null); + + ObjectMapper mapper = new ObjectMapper(); + mapper.writeValue(response.getOutputStream(), errorResponse); + } + +} \ No newline at end of file diff --git a/src/main/java/com/example/umc/study/config/filter/TestFilter.java b/src/main/java/com/example/umc/study/config/filter/TestFilter.java index 753299d..d13e1e0 100644 --- a/src/main/java/com/example/umc/study/config/filter/TestFilter.java +++ b/src/main/java/com/example/umc/study/config/filter/TestFilter.java @@ -1,8 +1,12 @@ package com.example.umc.study.config.filter; import jakarta.servlet.*; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.AuthenticationServiceException; import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Service; diff --git a/src/main/java/com/example/umc/study/dto/LoginRequestDTO.java b/src/main/java/com/example/umc/study/dto/LoginRequestDTO.java new file mode 100644 index 0000000..214262a --- /dev/null +++ b/src/main/java/com/example/umc/study/dto/LoginRequestDTO.java @@ -0,0 +1,10 @@ +package com.example.umc.study.dto; + +import lombok.Getter; + +@Getter +public class LoginRequestDTO { + + private String email; + private String password; +} diff --git a/src/main/java/com/example/umc/study/jwt/JWTUtil.java b/src/main/java/com/example/umc/study/jwt/JWTUtil.java new file mode 100644 index 0000000..91a159a --- /dev/null +++ b/src/main/java/com/example/umc/study/jwt/JWTUtil.java @@ -0,0 +1,87 @@ + +package com.example.umc.study.config.jwt; + +import com.example.umc.study.apiPayload.code.status.ErrorStatus; +import com.example.umc.study.apiPayload.exception.handler.AuthHandler; +import io.jsonwebtoken.*; +import io.jsonwebtoken.security.Keys; +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.time.ZonedDateTime; +import java.util.Date; + +@Component +@Slf4j +public class JWTUtil { + + private final SecretKey secretKey; + private final long accessTokenValidityMilliseconds; + + public JWTUtil( + @Value("${spring.jwt.secret}") final String secretKey, + @Value("${spring.jwt.access-token-time}") final long accessTokenValidityMilliseconds) { + this.secretKey = Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8)); + this.accessTokenValidityMilliseconds = accessTokenValidityMilliseconds; + } + + + // HTTP 요청의 'Authorization' 헤더에서 JWT 액세스 토큰을 검색 + public String resolveAccessToken(HttpServletRequest request) { + String authorization = request.getHeader("Authorization"); + + if (authorization == null || !authorization.startsWith("Bearer ")) { + throw new AuthHandler(ErrorStatus._TOKEN_NOT_FOUND); + } + return authorization.split(" ")[1]; + } + + public String createAccessToken(String email, String role) { + return createToken(email, role, accessTokenValidityMilliseconds); + } + + private String createToken(String email, String role, long validityMilliseconds) { + Claims claims = Jwts.claims(); + claims.put("email", email); + claims.put("role", role); + + ZonedDateTime now = ZonedDateTime.now(); + ZonedDateTime tokenValidity = now.plusSeconds(validityMilliseconds / 1000); + + return Jwts.builder() + .setClaims(claims) + .setIssuedAt(Date.from(now.toInstant())) + .setExpiration(Date.from(tokenValidity.toInstant())) + .signWith(secretKey, SignatureAlgorithm.HS256) + .compact(); + } + + public String getEmail(String token) { + return getClaims(token).getBody().get("email", String.class); + } + + public boolean isTokenValid(String token) { + try { + Jws claims = getClaims(token); + Date expiredDate = claims.getBody().getExpiration(); + Date now = new Date(); + return expiredDate.after(now); + } catch (ExpiredJwtException e) { + throw new AuthHandler(ErrorStatus._AUTH_EXPIRE_TOKEN); + } catch (SignatureException + | SecurityException + | IllegalArgumentException + | MalformedJwtException + | UnsupportedJwtException e) { + throw new AuthHandler(ErrorStatus._AUTH_INVALID_TOKEN); + } + } + + private Jws getClaims(String token) { + return Jwts.parserBuilder().setSigningKey(secretKey).build().parseClaimsJws(token); + } +} \ No newline at end of file