From f5b87529da5ba7dd126a674bd398e10a01c7f335 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=83=81=EC=9C=A4?= Date: Thu, 30 Jan 2025 19:24:22 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=EC=9C=A0=EC=A0=80=20=ED=9A=8C?= =?UTF-8?q?=EC=9B=90=EA=B0=80=EC=9E=85,=20=EC=9E=90=EC=B2=B4=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8,=20=ED=86=A0=ED=81=B0=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ref: #15 --- build.gradle | 109 ++++++++++++++++++ .../fondant/global/config/CorsMvcConfig.java | 16 +++ .../fondant/global/config/SecurityConfig.java | 86 ++++++++++++++ .../application/CustomUserDetailsService.java | 35 ++++++ .../user/application/ReissueService.java | 71 ++++++++++++ .../fondant/user/application/UserService.java | 51 ++++++++ .../application/dto/CustomUserDetails.java | 61 ++++++++++ .../fondant/user/domain/entity/SNSType.java | 2 +- .../user/domain/entity/UserEntity.java | 20 +++- .../fondant/user/domain/entity/UserRole.java | 5 + .../domain/repository/UserRepository.java | 12 ++ .../java/com/fondant/user/jwt/JWTFilter.java | 70 +++++++++++ .../java/com/fondant/user/jwt/JWTUtil.java | 47 ++++++++ .../com/fondant/user/jwt/LoginFilter.java | 94 +++++++++++++++ .../com/fondant/user/jwt/dto/JWTUserDTO.java | 12 ++ .../user/presentation/AdminController.java | 14 +++ .../user/presentation/UserController.java | 38 ++++++ .../presentation/dto/request/JoinRequest.java | 16 +++ 18 files changed, 755 insertions(+), 4 deletions(-) create mode 100644 build.gradle create mode 100644 src/main/java/com/fondant/global/config/CorsMvcConfig.java create mode 100644 src/main/java/com/fondant/global/config/SecurityConfig.java create mode 100644 src/main/java/com/fondant/user/application/CustomUserDetailsService.java create mode 100644 src/main/java/com/fondant/user/application/ReissueService.java create mode 100644 src/main/java/com/fondant/user/application/UserService.java create mode 100644 src/main/java/com/fondant/user/application/dto/CustomUserDetails.java create mode 100644 src/main/java/com/fondant/user/domain/entity/UserRole.java create mode 100644 src/main/java/com/fondant/user/domain/repository/UserRepository.java create mode 100644 src/main/java/com/fondant/user/jwt/JWTFilter.java create mode 100644 src/main/java/com/fondant/user/jwt/JWTUtil.java create mode 100644 src/main/java/com/fondant/user/jwt/LoginFilter.java create mode 100644 src/main/java/com/fondant/user/jwt/dto/JWTUserDTO.java create mode 100644 src/main/java/com/fondant/user/presentation/AdminController.java create mode 100644 src/main/java/com/fondant/user/presentation/UserController.java create mode 100644 src/main/java/com/fondant/user/presentation/dto/request/JoinRequest.java diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..7b65098 --- /dev/null +++ b/build.gradle @@ -0,0 +1,109 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '3.4.1' + id 'io.spring.dependency-management' version '1.1.7' + id 'org.asciidoctor.jvm.convert' version "3.3.2" +} + +group = 'com.fondant' +version = '0.0.1-SNAPSHOT' + +configurations { + asciidoctorExtensions +} + + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } +} + +configurations { + compileOnly { + extendsFrom annotationProcessor + } +} + +repositories { + mavenCentral() +} + +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-web' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + + //SPRING_SECURITY + implementation "org.springframework.boot:spring-boot-starter-security" + + //SPRING_REST_DOCS + asciidoctorExtensions 'org.springframework.restdocs:spring-restdocs-asciidoctor' + testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc' + + //JWT + implementation 'io.jsonwebtoken:jjwt-api:0.12.3' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.3' + + //VALIDATION + implementation 'org.springframework.boot:spring-boot-starter-validation' + + //POSTGRESQL + runtimeOnly 'org.postgresql:postgresql' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + + //LOMBOK + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + + //QUERY_DSL + implementation "com.querydsl:querydsl-jpa:5.0.0:jakarta" + implementation "com.querydsl:querydsl-core:5.0.0" + annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta" + annotationProcessor "jakarta.persistence:jakarta.persistence-api" + annotationProcessor "jakarta.annotation:jakarta.annotation-api" + + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' +} + + +def querydslDir = "build/generated/querydsl" + +sourceSets { + main.java.srcDirs += [querydslDir] +} + +tasks.withType(JavaCompile) { + options.getGeneratedSourceOutputDirectory().set(file(querydslDir)) +} + +clean.doLast { + file(querydslDir).deleteDir() +} + + +ext { + snippetsDir = file('build/generated-snippets') +} + +test { + outputs.dir snippetsDir +} + +asciidoctor { + inputs.dir snippetsDir + dependsOn test + configurations 'asciidoctorExtensions' +} + +bootJar { + dependsOn asciidoctor + copy { + from("${asciidoctor.outputDir}") + into 'src/main/resources/static/docs' + } +} + +tasks.named('test') { + useJUnitPlatform() +} diff --git a/src/main/java/com/fondant/global/config/CorsMvcConfig.java b/src/main/java/com/fondant/global/config/CorsMvcConfig.java new file mode 100644 index 0000000..eff14a7 --- /dev/null +++ b/src/main/java/com/fondant/global/config/CorsMvcConfig.java @@ -0,0 +1,16 @@ +package com.fondant.global.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class CorsMvcConfig implements WebMvcConfigurer { + + + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/**") + .allowedOrigins("http://localhost:3000"); + } +} diff --git a/src/main/java/com/fondant/global/config/SecurityConfig.java b/src/main/java/com/fondant/global/config/SecurityConfig.java new file mode 100644 index 0000000..2afc149 --- /dev/null +++ b/src/main/java/com/fondant/global/config/SecurityConfig.java @@ -0,0 +1,86 @@ +package com.fondant.global.config; + +import com.fondant.user.jwt.JWTFilter; +import com.fondant.user.jwt.JWTUtil; +import com.fondant.user.jwt.LoginFilter; +import jakarta.servlet.http.HttpServletRequest; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +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 java.util.Collections; + +@EnableWebSecurity +@Configuration +public class SecurityConfig { + + private final AuthenticationConfiguration authenticationConfiguration; + private final JWTUtil jwtUtil; + + + public SecurityConfig(AuthenticationConfiguration authenticationConfiguration, JWTUtil jwtUtil) { + this.authenticationConfiguration = authenticationConfiguration; + this.jwtUtil = jwtUtil; + } + + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception { + return authenticationConfiguration.getAuthenticationManager(); + } + + @Bean + public BCryptPasswordEncoder bCryptPasswordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .csrf(AbstractHttpConfigurer::disable); + + http + .cors((cors) -> cors + .configurationSource(request -> { + CorsConfiguration configration = new CorsConfiguration(); + configration.setAllowedOrigins(Collections.singletonList("http://localhost:3000")); + configration.setAllowedMethods(Collections.singletonList("*")); + configration.setAllowCredentials(true); + configration.setAllowedHeaders(Collections.singletonList("*")); + configration.setMaxAge(3600L); + + configration.setExposedHeaders(Collections.singletonList("Authorization")); + + return configration; + })); + + http + .formLogin(AbstractHttpConfigurer::disable); + + http + .authorizeHttpRequests((auth) -> auth + .requestMatchers("/api/user/join", "/api/user/login", "/api/user/reissue").permitAll() + .requestMatchers("/admin").hasRole("ADMIN") + .anyRequest().authenticated()); + + http + .addFilterBefore(new JWTFilter(jwtUtil), LoginFilter.class); + + http + .addFilterAt(new LoginFilter(authenticationManager(authenticationConfiguration), jwtUtil), UsernamePasswordAuthenticationFilter.class); + + http + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); + + return http.build(); + } +} diff --git a/src/main/java/com/fondant/user/application/CustomUserDetailsService.java b/src/main/java/com/fondant/user/application/CustomUserDetailsService.java new file mode 100644 index 0000000..70944e2 --- /dev/null +++ b/src/main/java/com/fondant/user/application/CustomUserDetailsService.java @@ -0,0 +1,35 @@ +package com.fondant.user.application; + +import com.fondant.user.application.dto.CustomUserDetails; +import com.fondant.user.jwt.dto.JWTUserDTO; +import com.fondant.user.domain.repository.UserRepository; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +@Service +public class CustomUserDetailsService implements UserDetailsService { + + private final UserRepository userRepository; + + public CustomUserDetailsService(UserRepository userRepository) { + this.userRepository = userRepository; + } + + + @Override + public UserDetails loadUserByUsername(String userEmail) throws UsernameNotFoundException{ + + JWTUserDTO userData = userRepository.findByEmail(userEmail) + .map(user -> JWTUserDTO.builder() + .userId(String.valueOf(user.getId())) + .userEmail(user.getEmail()) + .password(user.getPassword()) + .role(user.getRole().name()) + .build() + ).orElseThrow(() -> new UsernameNotFoundException("해당 이메일이 존재하지 않습니다.")); + + return new CustomUserDetails(userData); + } +} diff --git a/src/main/java/com/fondant/user/application/ReissueService.java b/src/main/java/com/fondant/user/application/ReissueService.java new file mode 100644 index 0000000..ccc4b29 --- /dev/null +++ b/src/main/java/com/fondant/user/application/ReissueService.java @@ -0,0 +1,71 @@ +package com.fondant.user.application; + +import com.fondant.user.jwt.JWTUtil; +import io.jsonwebtoken.ExpiredJwtException; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import java.io.IOException; +import java.io.PrintWriter; + +@Service +public class ReissueService { + + private final JWTUtil jwtUtil; + + public ReissueService(JWTUtil jwtUtil) { + this.jwtUtil = jwtUtil; + } + + public ResponseEntity reissueToken(HttpServletRequest request, HttpServletResponse response) throws IOException { + String refresh = null; + Cookie[] cookies = request.getCookies(); + + for (Cookie cookie : cookies) { + if (cookie.getName().equals("refresh")) { + refresh = cookie.getValue(); + } + } + + if (refresh == null) { + return new ResponseEntity<>("리프레쉬 토큰이 존재하지 않습니다.", HttpStatus.BAD_REQUEST); + } + + try { + jwtUtil.isTokenExpired(refresh); + } catch (ExpiredJwtException e) { + return new ResponseEntity<>("리프레쉬 토큰이 만료되었습니다.", HttpStatus.BAD_REQUEST); + } + + String type = jwtUtil.getType(refresh); + + if (!type.equals("refresh")) { + return new ResponseEntity<>("유효하지 않은 토큰입니다.", HttpStatus.BAD_REQUEST); + } + + String userId = jwtUtil.getUserIdFromToken(refresh); + String role = jwtUtil.getUserRoleFromToken(refresh); + + String newAccess = jwtUtil.generateToken("access", userId, role, 60 * 10L); + String newRefresh = jwtUtil.generateToken("refresh", userId, role, 60 * 60 * 24L); + + PrintWriter writer = response.getWriter(); + writer.print("access: " + newAccess); + response.addCookie(createCookie("refresh", newRefresh)); + + return new ResponseEntity<>(HttpStatus.OK); + } + + private Cookie createCookie(String key, String value) { + + Cookie cookie = new Cookie(key, value); + cookie.setMaxAge(60 * 60 * 24); + //cookie.setSecure(true); Https 사용시 + cookie.setPath("/"); + cookie.setHttpOnly(true); + return cookie; + } +} diff --git a/src/main/java/com/fondant/user/application/UserService.java b/src/main/java/com/fondant/user/application/UserService.java new file mode 100644 index 0000000..607656e --- /dev/null +++ b/src/main/java/com/fondant/user/application/UserService.java @@ -0,0 +1,51 @@ +package com.fondant.user.application; + +import com.fondant.user.presentation.dto.request.JoinRequest; +import com.fondant.user.domain.entity.SNSType; +import com.fondant.user.domain.entity.UserEntity; +import com.fondant.user.domain.entity.UserRole; +import com.fondant.user.domain.repository.UserRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; + +@Service +public class UserService { + + private final UserRepository userRepository; + + private final BCryptPasswordEncoder bCryptPasswordEncoder; + + @Autowired + public UserService(UserRepository userRepository, BCryptPasswordEncoder bCryptPasswordEncoder) { + this.userRepository = userRepository; + this.bCryptPasswordEncoder = bCryptPasswordEncoder; + } + + @Transactional + public UserEntity joinUser(JoinRequest request) { + + if (userRepository.findByEmail(request.email()).isPresent()) { + throw new IllegalArgumentException("이미 존재하는 이메일입니다."); + } + + UserEntity userEntity = UserEntity.builder() + .snsType(SNSType.LOCAL) + .name(request.name()) + .phoneNumber(request.phoneNumber()) + .email(request.email()) + .password(bCryptPasswordEncoder.encode(request.password())) + .birth(request.birth()) + .nickname("") + .profileUrl("") + .createAt(LocalDate.now()) + .gender(request.gender()) + .role(UserRole.USER) + .build(); + + return userRepository.save(userEntity); + } +} diff --git a/src/main/java/com/fondant/user/application/dto/CustomUserDetails.java b/src/main/java/com/fondant/user/application/dto/CustomUserDetails.java new file mode 100644 index 0000000..c0e654b --- /dev/null +++ b/src/main/java/com/fondant/user/application/dto/CustomUserDetails.java @@ -0,0 +1,61 @@ +package com.fondant.user.application.dto; + +import com.fondant.user.jwt.dto.JWTUserDTO; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.ArrayList; +import java.util.Collection; + +public class CustomUserDetails implements UserDetails { + + private final JWTUserDTO user; + + public CustomUserDetails(JWTUserDTO user) { + this.user = user; + } + + @Override + public Collection getAuthorities() { + + Collection collection = new ArrayList<>(); + + collection.add((GrantedAuthority) () -> "ROLE_" + user.role()); + + return collection; + } + + @Override + public String getPassword() { + return user.password(); + } + + @Override + public String getUsername() { + return user.userEmail(); + } + + @Override + public boolean isAccountNonExpired() { + return UserDetails.super.isAccountNonExpired(); + } + + @Override + public boolean isAccountNonLocked() { + return UserDetails.super.isAccountNonLocked(); + } + + @Override + public boolean isCredentialsNonExpired() { + return UserDetails.super.isCredentialsNonExpired(); + } + + @Override + public boolean isEnabled() { + return UserDetails.super.isEnabled(); + } + + public String getUserId(){ + return user.userId(); + } +} diff --git a/src/main/java/com/fondant/user/domain/entity/SNSType.java b/src/main/java/com/fondant/user/domain/entity/SNSType.java index e6ad832..a881769 100644 --- a/src/main/java/com/fondant/user/domain/entity/SNSType.java +++ b/src/main/java/com/fondant/user/domain/entity/SNSType.java @@ -1,5 +1,5 @@ package com.fondant.user.domain.entity; public enum SNSType { - KAKAO, NAVER, GOOGLE; + KAKAO, NAVER, GOOGLE, LOCAL; } diff --git a/src/main/java/com/fondant/user/domain/entity/UserEntity.java b/src/main/java/com/fondant/user/domain/entity/UserEntity.java index 8c8aa08..a52ccbd 100644 --- a/src/main/java/com/fondant/user/domain/entity/UserEntity.java +++ b/src/main/java/com/fondant/user/domain/entity/UserEntity.java @@ -4,6 +4,7 @@ import jakarta.validation.constraints.NotNull; import lombok.AccessLevel; import lombok.Builder; +import lombok.Getter; import lombok.NoArgsConstructor; import java.sql.Date; @@ -13,7 +14,8 @@ @Entity @NoArgsConstructor(access = AccessLevel.PROTECTED) -@Table(name="account") +@Table(name="users") +@Getter public class UserEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name="user_id") @@ -32,6 +34,9 @@ public class UserEntity { @Column(name="name") private String name; + @Column(name = "password") + private String password; + @NotNull @Column(name="phone_number") private String phoneNumber; @@ -50,23 +55,32 @@ public class UserEntity { private String profileUrl; @NotNull - @Column(name="create_at", updatable = false) + @Column(name="create_at") private LocalDate createAt; @NotNull @Column(name="gender") private Gender gender; + @NotNull + @Column(name="role") + private UserRole role; + @Builder - public UserEntity(SNSType snsType, String name, String phoneNumber, String email, Date birth, String nickname, String profileUrl, LocalDate createAt, Gender gender) { + public UserEntity( + SNSType snsType, String name, String phoneNumber, String email, + String password, Date birth, String nickname, + String profileUrl, LocalDate createAt, Gender gender, UserRole role) { this.snsType = snsType; this.name = name; this.phoneNumber = phoneNumber; this.email = email; + this.password = password; this.birth = birth; this.nickname = nickname; this.profileUrl = profileUrl; this.createAt = createAt; this.gender = gender; + this.role = role; } } diff --git a/src/main/java/com/fondant/user/domain/entity/UserRole.java b/src/main/java/com/fondant/user/domain/entity/UserRole.java new file mode 100644 index 0000000..3577c37 --- /dev/null +++ b/src/main/java/com/fondant/user/domain/entity/UserRole.java @@ -0,0 +1,5 @@ +package com.fondant.user.domain.entity; + +public enum UserRole { + ADMIN, USER; +} diff --git a/src/main/java/com/fondant/user/domain/repository/UserRepository.java b/src/main/java/com/fondant/user/domain/repository/UserRepository.java new file mode 100644 index 0000000..4e1404c --- /dev/null +++ b/src/main/java/com/fondant/user/domain/repository/UserRepository.java @@ -0,0 +1,12 @@ +package com.fondant.user.domain.repository; + +import com.fondant.user.domain.entity.UserEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface UserRepository extends JpaRepository { + Optional findByEmail(String email); +} \ No newline at end of file diff --git a/src/main/java/com/fondant/user/jwt/JWTFilter.java b/src/main/java/com/fondant/user/jwt/JWTFilter.java new file mode 100644 index 0000000..0b1fc09 --- /dev/null +++ b/src/main/java/com/fondant/user/jwt/JWTFilter.java @@ -0,0 +1,70 @@ +package com.fondant.user.jwt; + +import com.fondant.user.application.dto.CustomUserDetails; +import com.fondant.user.jwt.dto.JWTUserDTO; +import io.jsonwebtoken.ExpiredJwtException; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.OncePerRequestFilter; +import java.io.IOException; +import java.io.PrintWriter; + +public class JWTFilter extends OncePerRequestFilter { + + private final JWTUtil jwtUtil; + + public JWTFilter(JWTUtil jwtUtil) { + this.jwtUtil = jwtUtil; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + + String token = request.getHeader("Authorization"); + + if (token == null || !token.startsWith("Bearer ")) { + filterChain.doFilter(request, response); + return; + } + + String accessToken = token.substring(7); + + try { + jwtUtil.isTokenExpired(accessToken); + } catch (ExpiredJwtException e) { + PrintWriter writer = response.getWriter(); + writer.print("엑세스 토큰이 만료되었습니다."); + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + return; + } + + String type = jwtUtil.getType(accessToken); + + if (!type.equals("access")) { + PrintWriter writer = response.getWriter(); + writer.print("토큰이 유효하지 않습니다."); + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + return; + } + + String userId = jwtUtil.getUserIdFromToken(accessToken); + String role = jwtUtil.getUserRoleFromToken(accessToken); + + JWTUserDTO user = JWTUserDTO.builder() + .userId(userId) + .role(role) + .build(); + + CustomUserDetails customUserDetails = new CustomUserDetails(user); + + Authentication authToken = new UsernamePasswordAuthenticationToken(customUserDetails, null, customUserDetails.getAuthorities()); + SecurityContextHolder.getContext().setAuthentication(authToken); + + filterChain.doFilter(request, response); + } +} \ No newline at end of file diff --git a/src/main/java/com/fondant/user/jwt/JWTUtil.java b/src/main/java/com/fondant/user/jwt/JWTUtil.java new file mode 100644 index 0000000..2db6cb2 --- /dev/null +++ b/src/main/java/com/fondant/user/jwt/JWTUtil.java @@ -0,0 +1,47 @@ +package com.fondant.user.jwt; + +import io.jsonwebtoken.Jwts; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.util.Date; + +@Component +public class JWTUtil { + + private final SecretKey secretKey; + + public JWTUtil(@Value("${spring.jwt.secret}")String secret) { + + this.secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), Jwts.SIG.HS256.key().build().getAlgorithm()); + } + + public String getUserIdFromToken(String token) { + return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("userId").toString(); + } + + public String getUserRoleFromToken(String token) { + return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("role").toString(); + } + + public void isTokenExpired(String token) { + Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().getExpiration(); + } + + public String getType(String token) { + return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("type").toString(); + } + + public String generateToken(String type,String userId, String role, Long expiredMs) { + return Jwts.builder() + .claim("type",type) + .claim("userId", userId) + .claim("role", role) + .issuedAt(new Date(System.currentTimeMillis())) + .expiration(new Date(System.currentTimeMillis() + expiredMs)) + .signWith(secretKey) + .compact(); + } +} diff --git a/src/main/java/com/fondant/user/jwt/LoginFilter.java b/src/main/java/com/fondant/user/jwt/LoginFilter.java new file mode 100644 index 0000000..1d5c5b1 --- /dev/null +++ b/src/main/java/com/fondant/user/jwt/LoginFilter.java @@ -0,0 +1,94 @@ +package com.fondant.user.jwt; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fondant.user.application.dto.CustomUserDetails; +import jakarta.servlet.FilterChain; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.HttpStatus; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.AuthenticationServiceException; +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 org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import java.io.IOException; +import java.io.PrintWriter; +import java.util.Collection; +import java.util.Iterator; +import java.util.Map; + +public class LoginFilter extends UsernamePasswordAuthenticationFilter { + + private final AuthenticationManager authenticationManager; + private final JWTUtil jwtUtil; + private final ObjectMapper objectMapper = new ObjectMapper(); + + public LoginFilter(AuthenticationManager authenticationManager, JWTUtil jwtUtil) { + super.setRequiresAuthenticationRequestMatcher(new AntPathRequestMatcher("/api/user/login", "POST")); + this.authenticationManager = authenticationManager; + this.jwtUtil = jwtUtil; + } + + @Override + public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { + String userEmail; + String password; + + try { + Map jsonRequest = objectMapper.readValue(request.getInputStream(), Map.class); + userEmail = (String) jsonRequest.get("email"); + password = (String) jsonRequest.get("password"); + + if (userEmail == null || password == null) { + throw new AuthenticationException("메일 또는 비밀번호가 입력되지 않았습니다.") { + }; + } + + UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(userEmail, password); + return authenticationManager.authenticate(authToken); + } catch (IOException e) { + throw new AuthenticationServiceException("JSON 요청을 읽는 중 오류가 발생했습니다.", e) { + }; + } + } + + @Override + protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authentication) throws IOException { + CustomUserDetails customUserDetails = (CustomUserDetails) authentication.getPrincipal(); + + String userId = customUserDetails.getUserId(); + + Collection authorities = authentication.getAuthorities(); + Iterator iterator = authorities.iterator(); + GrantedAuthority auth = iterator.next(); + + String role = auth.getAuthority(); + + String accessToken = jwtUtil.generateToken("access", userId, role, 60 * 10L); + String refreshToken = jwtUtil.generateToken("refresh", userId, role, 60 * 60 * 24L); + + PrintWriter writer = response.getWriter(); + writer.print("access: " + accessToken); + response.addCookie(createCookie("refresh", refreshToken)); + response.setStatus(HttpStatus.OK.value()); + } + + @Override + protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed){ + response.setStatus(401); + } + + private Cookie createCookie(String key, String value) { + + Cookie cookie = new Cookie(key, value); + cookie.setMaxAge(60 * 60 * 24); + //cookie.setSecure(true); Https 사용시 + cookie.setPath("/"); + cookie.setHttpOnly(true); + return cookie; + } +} diff --git a/src/main/java/com/fondant/user/jwt/dto/JWTUserDTO.java b/src/main/java/com/fondant/user/jwt/dto/JWTUserDTO.java new file mode 100644 index 0000000..3ddb8f5 --- /dev/null +++ b/src/main/java/com/fondant/user/jwt/dto/JWTUserDTO.java @@ -0,0 +1,12 @@ +package com.fondant.user.jwt.dto; + +import lombok.Builder; + +@Builder +public record JWTUserDTO( + String userEmail, + String password, + String userId, + String role +) { +} diff --git a/src/main/java/com/fondant/user/presentation/AdminController.java b/src/main/java/com/fondant/user/presentation/AdminController.java new file mode 100644 index 0000000..90b530a --- /dev/null +++ b/src/main/java/com/fondant/user/presentation/AdminController.java @@ -0,0 +1,14 @@ +package com.fondant.user.presentation; + +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ResponseBody; + +@Controller +@ResponseBody +public class AdminController { + @GetMapping("/admin") + public String adminTest() { + return "Admin Controller"; + } +} diff --git a/src/main/java/com/fondant/user/presentation/UserController.java b/src/main/java/com/fondant/user/presentation/UserController.java new file mode 100644 index 0000000..3c2b277 --- /dev/null +++ b/src/main/java/com/fondant/user/presentation/UserController.java @@ -0,0 +1,38 @@ +package com.fondant.user.presentation; + +import com.fondant.user.application.ReissueService; +import com.fondant.user.application.UserService; +import com.fondant.user.presentation.dto.request.JoinRequest; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; + +import java.io.IOException; + +@Controller +@RequestMapping("api/user") +public class UserController { + + private final UserService userService; + private final ReissueService reissueService; + + public UserController(UserService userService, ReissueService reissueService) { + this.userService = userService; + this.reissueService = reissueService; + } + + @PostMapping("/join") + public ResponseEntity join(@RequestBody JoinRequest joinRequest) { + userService.joinUser(joinRequest); + return ResponseEntity.ok("회원가입이 완료되었습니다."); + } + + @PostMapping("/reissue") + public ResponseEntity reissue(HttpServletRequest request, HttpServletResponse response) throws IOException { + return reissueService.reissueToken(request, response); + } +} diff --git a/src/main/java/com/fondant/user/presentation/dto/request/JoinRequest.java b/src/main/java/com/fondant/user/presentation/dto/request/JoinRequest.java new file mode 100644 index 0000000..a56a866 --- /dev/null +++ b/src/main/java/com/fondant/user/presentation/dto/request/JoinRequest.java @@ -0,0 +1,16 @@ +package com.fondant.user.presentation.dto.request; + +import com.fondant.user.domain.entity.Gender; +import lombok.Builder; + +import java.sql.Date; + +@Builder +public record JoinRequest ( + String name, + String phoneNumber, + String email, + String password, + Date birth, + Gender gender){ +} \ No newline at end of file From 276a87a41f2fdd300c9a32f26875cc34bb7d158e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=83=81=EC=9C=A4?= Date: Tue, 4 Feb 2025 14:34:23 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20Refresh=20Rotate,=20Refresh=20Token?= =?UTF-8?q?=20DB=20=EC=A0=80=EC=9E=A5=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ref: #15 --- .../fondant/global/config/QueryDslConfig.java | 18 ++++++++++++ .../global/config/SchedulingConfig.java | 10 +++++++ .../fondant/global/config/SecurityConfig.java | 22 ++++++++------ .../jwt/application}/JWTUtil.java | 20 ++++++++++--- .../jwt/application/SchedulerService.java | 23 +++++++++++++++ .../jwt/domain/entity/RefreshEntity.java | 29 +++++++++++++++++++ .../repository/JwtRepositoryCustom.java | 7 +++++ .../domain/repository/JwtRepositoryImpl.java | 24 +++++++++++++++ .../domain/repository/RefreshRepository.java | 17 +++++++++++ .../{user => infra}/jwt/dto/JWTUserDTO.java | 2 +- .../jwt => infra/jwt/filter}/JWTFilter.java | 5 ++-- .../jwt => infra/jwt/filter}/LoginFilter.java | 28 +++++++++++++++--- 12 files changed, 185 insertions(+), 20 deletions(-) create mode 100644 src/main/java/com/fondant/global/config/QueryDslConfig.java create mode 100644 src/main/java/com/fondant/global/config/SchedulingConfig.java rename src/main/java/com/fondant/{user/jwt => infra/jwt/application}/JWTUtil.java (70%) create mode 100644 src/main/java/com/fondant/infra/jwt/application/SchedulerService.java create mode 100644 src/main/java/com/fondant/infra/jwt/domain/entity/RefreshEntity.java create mode 100644 src/main/java/com/fondant/infra/jwt/domain/repository/JwtRepositoryCustom.java create mode 100644 src/main/java/com/fondant/infra/jwt/domain/repository/JwtRepositoryImpl.java create mode 100644 src/main/java/com/fondant/infra/jwt/domain/repository/RefreshRepository.java rename src/main/java/com/fondant/{user => infra}/jwt/dto/JWTUserDTO.java (82%) rename src/main/java/com/fondant/{user/jwt => infra/jwt/filter}/JWTFilter.java (94%) rename src/main/java/com/fondant/{user/jwt => infra/jwt/filter}/LoginFilter.java (81%) diff --git a/src/main/java/com/fondant/global/config/QueryDslConfig.java b/src/main/java/com/fondant/global/config/QueryDslConfig.java new file mode 100644 index 0000000..c0e35dc --- /dev/null +++ b/src/main/java/com/fondant/global/config/QueryDslConfig.java @@ -0,0 +1,18 @@ +package com.fondant.global.config; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class QueryDslConfig { + @PersistenceContext + private EntityManager entityManager; + + @Bean + public JPAQueryFactory jpaQueryFactory() { + return new JPAQueryFactory(entityManager); + } +} \ No newline at end of file diff --git a/src/main/java/com/fondant/global/config/SchedulingConfig.java b/src/main/java/com/fondant/global/config/SchedulingConfig.java new file mode 100644 index 0000000..2487f4e --- /dev/null +++ b/src/main/java/com/fondant/global/config/SchedulingConfig.java @@ -0,0 +1,10 @@ +package com.fondant.global.config; + + +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableScheduling; + +@Configuration +@EnableScheduling +public class SchedulingConfig { +} diff --git a/src/main/java/com/fondant/global/config/SecurityConfig.java b/src/main/java/com/fondant/global/config/SecurityConfig.java index 2afc149..55028f5 100644 --- a/src/main/java/com/fondant/global/config/SecurityConfig.java +++ b/src/main/java/com/fondant/global/config/SecurityConfig.java @@ -1,10 +1,11 @@ package com.fondant.global.config; -import com.fondant.user.jwt.JWTFilter; -import com.fondant.user.jwt.JWTUtil; -import com.fondant.user.jwt.LoginFilter; -import jakarta.servlet.http.HttpServletRequest; +import com.fondant.infra.jwt.filter.CustomLogoutFilter; +import com.fondant.infra.jwt.filter.JWTFilter; +import com.fondant.infra.jwt.application.JWTUtil; +import com.fondant.infra.jwt.filter.LoginFilter; +import com.fondant.infra.jwt.domain.repository.RefreshRepository; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.authentication.AuthenticationManager; @@ -16,8 +17,8 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.security.web.authentication.logout.LogoutFilter; import org.springframework.web.cors.CorsConfiguration; -import org.springframework.web.cors.CorsConfigurationSource; import java.util.Collections; @EnableWebSecurity @@ -26,11 +27,13 @@ public class SecurityConfig { private final AuthenticationConfiguration authenticationConfiguration; private final JWTUtil jwtUtil; + private final RefreshRepository refreshRepository; - public SecurityConfig(AuthenticationConfiguration authenticationConfiguration, JWTUtil jwtUtil) { + public SecurityConfig(AuthenticationConfiguration authenticationConfiguration, JWTUtil jwtUtil, RefreshRepository refreshRepository) { this.authenticationConfiguration = authenticationConfiguration; this.jwtUtil = jwtUtil; + this.refreshRepository = refreshRepository; } @Bean @@ -57,9 +60,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti configration.setAllowCredentials(true); configration.setAllowedHeaders(Collections.singletonList("*")); configration.setMaxAge(3600L); - configration.setExposedHeaders(Collections.singletonList("Authorization")); - return configration; })); @@ -76,7 +77,10 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .addFilterBefore(new JWTFilter(jwtUtil), LoginFilter.class); http - .addFilterAt(new LoginFilter(authenticationManager(authenticationConfiguration), jwtUtil), UsernamePasswordAuthenticationFilter.class); + .addFilterAt(new LoginFilter(authenticationManager(authenticationConfiguration), jwtUtil, refreshRepository), UsernamePasswordAuthenticationFilter.class); + + http + .addFilterAt(new CustomLogoutFilter(jwtUtil, refreshRepository), LogoutFilter.class); http .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); diff --git a/src/main/java/com/fondant/user/jwt/JWTUtil.java b/src/main/java/com/fondant/infra/jwt/application/JWTUtil.java similarity index 70% rename from src/main/java/com/fondant/user/jwt/JWTUtil.java rename to src/main/java/com/fondant/infra/jwt/application/JWTUtil.java index 2db6cb2..003d009 100644 --- a/src/main/java/com/fondant/user/jwt/JWTUtil.java +++ b/src/main/java/com/fondant/infra/jwt/application/JWTUtil.java @@ -1,4 +1,4 @@ -package com.fondant.user.jwt; +package com.fondant.infra.jwt.application; import io.jsonwebtoken.Jwts; import org.springframework.beans.factory.annotation.Value; @@ -6,6 +6,8 @@ import javax.crypto.SecretKey; import javax.crypto.spec.SecretKeySpec; import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.time.ZoneId; import java.util.Date; @Component @@ -14,7 +16,6 @@ public class JWTUtil { private final SecretKey secretKey; public JWTUtil(@Value("${spring.jwt.secret}")String secret) { - this.secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), Jwts.SIG.HS256.key().build().getAlgorithm()); } @@ -26,14 +27,25 @@ public String getUserRoleFromToken(String token) { return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("role").toString(); } - public void isTokenExpired(String token) { - Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().getExpiration(); + public boolean isTokenExpired(String token) { + return Jwts.parser() + .setSigningKey(secretKey) + .build() + .parseClaimsJws(token) + .getBody() + .getExpiration() + .before(new Date(System.currentTimeMillis())); } public String getType(String token) { return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("type").toString(); } + public LocalDateTime getExpiration(String token) { + return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().getExpiration() + .toInstant().atZone(ZoneId.of("Asia/Seoul")).toLocalDateTime(); + } + public String generateToken(String type,String userId, String role, Long expiredMs) { return Jwts.builder() .claim("type",type) diff --git a/src/main/java/com/fondant/infra/jwt/application/SchedulerService.java b/src/main/java/com/fondant/infra/jwt/application/SchedulerService.java new file mode 100644 index 0000000..ab88a4e --- /dev/null +++ b/src/main/java/com/fondant/infra/jwt/application/SchedulerService.java @@ -0,0 +1,23 @@ +package com.fondant.infra.jwt.application; + +import com.fondant.infra.jwt.domain.repository.RefreshRepository; +import jakarta.transaction.Transactional; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import java.time.LocalDateTime; + +@Service +@Transactional +public class SchedulerService { + private final RefreshRepository refreshRepository; + + public SchedulerService(RefreshRepository refreshRepository) { + this.refreshRepository = refreshRepository; + } + + @Scheduled(fixedRate = 60 * 60 * 24 * 1000) + public void deleteAllExpires() { + LocalDateTime now = LocalDateTime.now(); + refreshRepository.deleteByExpiresBefore(now); + } +} diff --git a/src/main/java/com/fondant/infra/jwt/domain/entity/RefreshEntity.java b/src/main/java/com/fondant/infra/jwt/domain/entity/RefreshEntity.java new file mode 100644 index 0000000..6e58b38 --- /dev/null +++ b/src/main/java/com/fondant/infra/jwt/domain/entity/RefreshEntity.java @@ -0,0 +1,29 @@ +package com.fondant.infra.jwt.domain.entity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.NoArgsConstructor; +import java.time.LocalDateTime; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name="refresh") +public class RefreshEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String userId; + private String refresh; + @Column(name = "expires", columnDefinition = "TIMESTAMP WITHOUT TIME ZONE") + private LocalDateTime expires; + + @Builder + public RefreshEntity(String userId, String refresh, LocalDateTime expires) { + this.userId = userId; + this.refresh = refresh; + this.expires = expires; + } +} diff --git a/src/main/java/com/fondant/infra/jwt/domain/repository/JwtRepositoryCustom.java b/src/main/java/com/fondant/infra/jwt/domain/repository/JwtRepositoryCustom.java new file mode 100644 index 0000000..766b470 --- /dev/null +++ b/src/main/java/com/fondant/infra/jwt/domain/repository/JwtRepositoryCustom.java @@ -0,0 +1,7 @@ +package com.fondant.infra.jwt.domain.repository; + +import java.time.LocalDateTime; + +public interface JwtRepositoryCustom { + void deleteByExpiresBefore(LocalDateTime now); +} diff --git a/src/main/java/com/fondant/infra/jwt/domain/repository/JwtRepositoryImpl.java b/src/main/java/com/fondant/infra/jwt/domain/repository/JwtRepositoryImpl.java new file mode 100644 index 0000000..1815a31 --- /dev/null +++ b/src/main/java/com/fondant/infra/jwt/domain/repository/JwtRepositoryImpl.java @@ -0,0 +1,24 @@ +package com.fondant.infra.jwt.domain.repository; + +import com.fondant.infra.jwt.domain.entity.QRefreshEntity; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import java.time.LocalDateTime; + +@RequiredArgsConstructor +public class JwtRepositoryImpl implements JwtRepositoryCustom { + + private final JPAQueryFactory queryFactory; + + public void deleteByExpiresBefore(LocalDateTime now) { + QRefreshEntity refresh = QRefreshEntity.refreshEntity; + + try { + queryFactory.delete(refresh) + .where(refresh.expires.before(now)) + .execute(); + } catch (Exception e) { + throw new RuntimeException("만료된 리프레쉬 토큰을 제거하는데 실패했습니다."); + } + } +} diff --git a/src/main/java/com/fondant/infra/jwt/domain/repository/RefreshRepository.java b/src/main/java/com/fondant/infra/jwt/domain/repository/RefreshRepository.java new file mode 100644 index 0000000..e8eb885 --- /dev/null +++ b/src/main/java/com/fondant/infra/jwt/domain/repository/RefreshRepository.java @@ -0,0 +1,17 @@ +package com.fondant.infra.jwt.domain.repository; + +import com.fondant.infra.jwt.domain.entity.RefreshEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +public interface RefreshRepository extends JpaRepository, JwtRepositoryCustom { + + Boolean existsByRefresh(String refresh); + + @Transactional + void deleteByRefresh(String refresh); + + void deleteByExpiresBefore(LocalDateTime now); +} diff --git a/src/main/java/com/fondant/user/jwt/dto/JWTUserDTO.java b/src/main/java/com/fondant/infra/jwt/dto/JWTUserDTO.java similarity index 82% rename from src/main/java/com/fondant/user/jwt/dto/JWTUserDTO.java rename to src/main/java/com/fondant/infra/jwt/dto/JWTUserDTO.java index 3ddb8f5..1eab868 100644 --- a/src/main/java/com/fondant/user/jwt/dto/JWTUserDTO.java +++ b/src/main/java/com/fondant/infra/jwt/dto/JWTUserDTO.java @@ -1,4 +1,4 @@ -package com.fondant.user.jwt.dto; +package com.fondant.infra.jwt.dto; import lombok.Builder; diff --git a/src/main/java/com/fondant/user/jwt/JWTFilter.java b/src/main/java/com/fondant/infra/jwt/filter/JWTFilter.java similarity index 94% rename from src/main/java/com/fondant/user/jwt/JWTFilter.java rename to src/main/java/com/fondant/infra/jwt/filter/JWTFilter.java index 0b1fc09..a9fa35a 100644 --- a/src/main/java/com/fondant/user/jwt/JWTFilter.java +++ b/src/main/java/com/fondant/infra/jwt/filter/JWTFilter.java @@ -1,7 +1,8 @@ -package com.fondant.user.jwt; +package com.fondant.infra.jwt.filter; +import com.fondant.infra.jwt.application.JWTUtil; import com.fondant.user.application.dto.CustomUserDetails; -import com.fondant.user.jwt.dto.JWTUserDTO; +import com.fondant.infra.jwt.dto.JWTUserDTO; import io.jsonwebtoken.ExpiredJwtException; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; diff --git a/src/main/java/com/fondant/user/jwt/LoginFilter.java b/src/main/java/com/fondant/infra/jwt/filter/LoginFilter.java similarity index 81% rename from src/main/java/com/fondant/user/jwt/LoginFilter.java rename to src/main/java/com/fondant/infra/jwt/filter/LoginFilter.java index 1d5c5b1..acbd002 100644 --- a/src/main/java/com/fondant/user/jwt/LoginFilter.java +++ b/src/main/java/com/fondant/infra/jwt/filter/LoginFilter.java @@ -1,6 +1,9 @@ -package com.fondant.user.jwt; +package com.fondant.infra.jwt.filter; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fondant.infra.jwt.application.JWTUtil; +import com.fondant.infra.jwt.domain.entity.RefreshEntity; +import com.fondant.infra.jwt.domain.repository.RefreshRepository; import com.fondant.user.application.dto.CustomUserDetails; import jakarta.servlet.FilterChain; import jakarta.servlet.http.Cookie; @@ -17,6 +20,7 @@ import org.springframework.security.web.util.matcher.AntPathRequestMatcher; import java.io.IOException; import java.io.PrintWriter; +import java.time.LocalDateTime; import java.util.Collection; import java.util.Iterator; import java.util.Map; @@ -26,11 +30,13 @@ public class LoginFilter extends UsernamePasswordAuthenticationFilter { private final AuthenticationManager authenticationManager; private final JWTUtil jwtUtil; private final ObjectMapper objectMapper = new ObjectMapper(); + private RefreshRepository refreshRepository; - public LoginFilter(AuthenticationManager authenticationManager, JWTUtil jwtUtil) { + public LoginFilter(AuthenticationManager authenticationManager, JWTUtil jwtUtil, RefreshRepository refreshRepository) { super.setRequiresAuthenticationRequestMatcher(new AntPathRequestMatcher("/api/user/login", "POST")); this.authenticationManager = authenticationManager; this.jwtUtil = jwtUtil; + this.refreshRepository = refreshRepository; } @Override @@ -68,8 +74,10 @@ protected void successfulAuthentication(HttpServletRequest request, HttpServletR String role = auth.getAuthority(); - String accessToken = jwtUtil.generateToken("access", userId, role, 60 * 10L); - String refreshToken = jwtUtil.generateToken("refresh", userId, role, 60 * 60 * 24L); + String accessToken = jwtUtil.generateToken("access", userId, role, 60 * 10 * 1000L); + String refreshToken = jwtUtil.generateToken("refresh", userId, role, 60 * 60 * 24 * 1000L); + + addRefreshEntity(userId, refreshToken); PrintWriter writer = response.getWriter(); writer.print("access: " + accessToken); @@ -77,6 +85,18 @@ protected void successfulAuthentication(HttpServletRequest request, HttpServletR response.setStatus(HttpStatus.OK.value()); } + private void addRefreshEntity(String userId, String refresh) { + LocalDateTime date = jwtUtil.getExpiration(refresh); + + RefreshEntity refreshEntity = RefreshEntity.builder() + .userId(userId) + .refresh(refresh) + .expires(date) + .build(); + + refreshRepository.save(refreshEntity); + } + @Override protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed){ response.setStatus(401); From 57394bf31eccb55f4aa8eec28b1f39833c1123bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=83=81=EC=9C=A4?= Date: Tue, 4 Feb 2025 15:06:59 +0900 Subject: [PATCH 3/4] =?UTF-8?q?feat:=20=EB=88=84=EB=9D=BD=EB=90=9C=20Refre?= =?UTF-8?q?sh=20Rotate,=20Refresh=20Token=20DB=20=EC=A0=80=EC=9E=A5=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=ED=8C=8C=EC=9D=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ref: #15 --- .../application/CustomUserDetailsService.java | 2 +- .../user/application/ReissueService.java | 31 +++++++++++++++++-- .../application/dto/CustomUserDetails.java | 2 +- 3 files changed, 30 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/fondant/user/application/CustomUserDetailsService.java b/src/main/java/com/fondant/user/application/CustomUserDetailsService.java index 70944e2..fa97762 100644 --- a/src/main/java/com/fondant/user/application/CustomUserDetailsService.java +++ b/src/main/java/com/fondant/user/application/CustomUserDetailsService.java @@ -1,7 +1,7 @@ package com.fondant.user.application; import com.fondant.user.application.dto.CustomUserDetails; -import com.fondant.user.jwt.dto.JWTUserDTO; +import com.fondant.infra.jwt.dto.JWTUserDTO; import com.fondant.user.domain.repository.UserRepository; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; diff --git a/src/main/java/com/fondant/user/application/ReissueService.java b/src/main/java/com/fondant/user/application/ReissueService.java index ccc4b29..d718cb2 100644 --- a/src/main/java/com/fondant/user/application/ReissueService.java +++ b/src/main/java/com/fondant/user/application/ReissueService.java @@ -1,6 +1,8 @@ package com.fondant.user.application; -import com.fondant.user.jwt.JWTUtil; +import com.fondant.infra.jwt.application.JWTUtil; +import com.fondant.infra.jwt.domain.entity.RefreshEntity; +import com.fondant.infra.jwt.domain.repository.RefreshRepository; import io.jsonwebtoken.ExpiredJwtException; import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; @@ -10,14 +12,18 @@ import org.springframework.stereotype.Service; import java.io.IOException; import java.io.PrintWriter; +import java.time.LocalDateTime; +import java.time.ZoneId; @Service public class ReissueService { private final JWTUtil jwtUtil; + private final RefreshRepository refreshRepository; - public ReissueService(JWTUtil jwtUtil) { + public ReissueService(JWTUtil jwtUtil, RefreshRepository refreshRepository) { this.jwtUtil = jwtUtil; + this.refreshRepository = refreshRepository; } public ResponseEntity reissueToken(HttpServletRequest request, HttpServletResponse response) throws IOException { @@ -46,12 +52,19 @@ public ResponseEntity reissueToken(HttpServletRequest request, HttpServletRes return new ResponseEntity<>("유효하지 않은 토큰입니다.", HttpStatus.BAD_REQUEST); } + if (!refreshRepository.existsByRefresh(refresh)){ + return new ResponseEntity<>("존재하지 않는 토큰입니다.", HttpStatus.BAD_REQUEST); + } + String userId = jwtUtil.getUserIdFromToken(refresh); String role = jwtUtil.getUserRoleFromToken(refresh); String newAccess = jwtUtil.generateToken("access", userId, role, 60 * 10L); String newRefresh = jwtUtil.generateToken("refresh", userId, role, 60 * 60 * 24L); + refreshRepository.deleteByRefresh(refresh); + addRefreshEntity(userId, newRefresh, 60 * 60 * 24L); + PrintWriter writer = response.getWriter(); writer.print("access: " + newAccess); response.addCookie(createCookie("refresh", newRefresh)); @@ -68,4 +81,16 @@ private Cookie createCookie(String key, String value) { cookie.setHttpOnly(true); return cookie; } -} + + private void addRefreshEntity(String userId, String refresh, Long expiredMs) { + LocalDateTime date = LocalDateTime.now().plusSeconds(expiredMs).atZone(ZoneId.systemDefault()).toLocalDateTime(); + + RefreshEntity refreshEntity = RefreshEntity.builder() + .userId(userId) + .refresh(refresh) + .expires(date) + .build(); + + refreshRepository.save(refreshEntity); + } +} \ No newline at end of file diff --git a/src/main/java/com/fondant/user/application/dto/CustomUserDetails.java b/src/main/java/com/fondant/user/application/dto/CustomUserDetails.java index c0e654b..01882fb 100644 --- a/src/main/java/com/fondant/user/application/dto/CustomUserDetails.java +++ b/src/main/java/com/fondant/user/application/dto/CustomUserDetails.java @@ -1,6 +1,6 @@ package com.fondant.user.application.dto; -import com.fondant.user.jwt.dto.JWTUserDTO; +import com.fondant.infra.jwt.dto.JWTUserDTO; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; From d0d516748bbc937aa9020060eadd89f471c90ec6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=83=81=EC=9C=A4?= Date: Tue, 4 Feb 2025 15:08:11 +0900 Subject: [PATCH 4/4] =?UTF-8?q?feat:=20Logout=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ref: #15 --- .../infra/jwt/filter/CustomLogoutFilter.java | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 src/main/java/com/fondant/infra/jwt/filter/CustomLogoutFilter.java diff --git a/src/main/java/com/fondant/infra/jwt/filter/CustomLogoutFilter.java b/src/main/java/com/fondant/infra/jwt/filter/CustomLogoutFilter.java new file mode 100644 index 0000000..f019cb3 --- /dev/null +++ b/src/main/java/com/fondant/infra/jwt/filter/CustomLogoutFilter.java @@ -0,0 +1,85 @@ +package com.fondant.infra.jwt.filter; + + +import com.fondant.infra.jwt.application.JWTUtil; +import com.fondant.infra.jwt.domain.repository.RefreshRepository; +import io.jsonwebtoken.ExpiredJwtException; +import jakarta.servlet.*; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.web.filter.GenericFilterBean; + +import java.io.IOException; + +public class CustomLogoutFilter extends GenericFilterBean { + + private final JWTUtil jwtUtil; + private final RefreshRepository refreshRepository; + + public CustomLogoutFilter(JWTUtil jwtUtil, RefreshRepository refreshRepository) { + this.jwtUtil = jwtUtil; + this.refreshRepository = refreshRepository; + } + + @Override + public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException { + doFilter((HttpServletRequest) servletRequest, (HttpServletResponse)servletResponse, filterChain); + } + + private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws IOException, ServletException { + + String requestURI = request.getRequestURI(); + String requestMethod = request.getMethod(); + + if (!requestURI.contains("/logout")) { + filterChain.doFilter(request, response); + return; + } + + if (!requestMethod.equals("POST")) { + filterChain.doFilter(request, response); + return; + } + + String refresh = null; + Cookie[] cookies = request.getCookies(); + + for (Cookie cookie : cookies) { + if (cookie.getName().equals("refresh")) { + refresh = cookie.getValue(); + } + } + + if (refresh == null) { + response.setStatus(HttpServletResponse.SC_BAD_REQUEST); + return; + } + + if (jwtUtil.isTokenExpired(refresh)){ + response.setStatus(HttpServletResponse.SC_BAD_REQUEST); + } + System.out.println(refresh); + + String type = jwtUtil.getType(refresh); + + if (!type.equals("refresh")) { + response.setStatus(HttpServletResponse.SC_BAD_REQUEST); + return; + } + + if(!refreshRepository.existsByRefresh(refresh)) { + response.setStatus(HttpServletResponse.SC_BAD_REQUEST); + return; + } + + refreshRepository.deleteByRefresh(refresh); + + Cookie cookie = new Cookie("refresh", null); + cookie.setMaxAge(0); + cookie.setPath("/"); + response.addCookie(cookie); + response.setStatus(HttpServletResponse.SC_OK); + + } +}