diff --git a/README.md b/README.md index 8dc5c6a..c01c341 100644 --- a/README.md +++ b/README.md @@ -1 +1,9 @@ # hw4 + +## JWT의 목적 + ++ 모바일 앱 : 특성상 JWT 방식으로 인증 + STATELESS(부수적 효과) ++ 모바일 앱 로그아웃 : 모바일 앱에서는 JWT 탈취 우려 X, +로그아웃 -> JWT 제거하면 추가 조치 필요 없음 ++ 장시간 로그인 : 세션으로 -> 서버 측 부하 + diff --git a/build.gradle b/build.gradle index d6dd785..92ece06 100644 --- a/build.gradle +++ b/build.gradle @@ -26,8 +26,14 @@ repositories { dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-security' compileOnly 'org.projectlombok:lombok' runtimeOnly 'com.mysql:mysql-connector-j' + + implementation 'io.jsonwebtoken:jjwt-api:0.12.3' + implementation 'io.jsonwebtoken:jjwt-impl:0.12.3' + implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3' + annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' diff --git a/src/main/java/com/example/springhw4/Config/CorsMvcConfig.java b/src/main/java/com/example/springhw4/Config/CorsMvcConfig.java new file mode 100644 index 0000000..ecccf9b --- /dev/null +++ b/src/main/java/com/example/springhw4/Config/CorsMvcConfig.java @@ -0,0 +1,16 @@ +package com.example.springhw4.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 corsRegistry) { + + corsRegistry.addMapping("/**") + .allowedOrigins("http://localhost:3000"); + } +} diff --git a/src/main/java/com/example/springhw4/Config/SecurityConfig.java b/src/main/java/com/example/springhw4/Config/SecurityConfig.java new file mode 100644 index 0000000..0a41063 --- /dev/null +++ b/src/main/java/com/example/springhw4/Config/SecurityConfig.java @@ -0,0 +1,104 @@ +package com.example.springhw4.Config; + +import com.example.springhw4.jwt.JWTFilter; +import com.example.springhw4.jwt.JWTUtil; +import com.example.springhw4.jwt.LoginFilter; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +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; + + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final AuthenticationConfiguration authenticationConfiguration; + private final JWTUtil jwtUtil; + + //AuthenticationManager Bean 등록 + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception { + + return configuration.getAuthenticationManager(); + } + + + @Bean + public BCryptPasswordEncoder bCryptPasswordEncoder() { + + return new BCryptPasswordEncoder(); + } + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception{ + + http + .cors((cors)->cors + .configurationSource(new CorsConfigurationSource() { + @Override + public CorsConfiguration getCorsConfiguration(HttpServletRequest request) { + + CorsConfiguration configuration = new CorsConfiguration(); + + configuration.setAllowedOrigins(Collections.singletonList("http://localhost:3000")); + configuration.setAllowedMethods(Collections.singletonList("*")); + configuration.setAllowCredentials(true); + configuration.setAllowedHeaders(Collections.singletonList("*")); + configuration.setMaxAge(3600L); + + configuration.setExposedHeaders(Collections.singletonList("Authorization")); + + return configuration; + } + })); + + // csrf disable + // 세션 방식 : 세션 고정 -> csrf 공격 방어 + // 토큰 방식 : 세션 stateless -> 방어 굳이 x + http + .csrf(AbstractHttpConfigurer::disable); + + // Form 로그인 방식 disable + http + .formLogin(AbstractHttpConfigurer::disable); + + // http basic 인증 방식 disable + http + .httpBasic(AbstractHttpConfigurer::disable); + + http + .authorizeHttpRequests((auth)->auth + .requestMatchers("login","/","/join").permitAll() + .requestMatchers("/admin").hasRole("ADMIN") + .anyRequest().authenticated()); + + http + .addFilterAt(new JWTFilter(jwtUtil), LoginFilter.class); + http + // 정확한 자리에 대체 하기 위해 At + .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/example/springhw4/controller/AdminController.java b/src/main/java/com/example/springhw4/controller/AdminController.java new file mode 100644 index 0000000..4f63655 --- /dev/null +++ b/src/main/java/com/example/springhw4/controller/AdminController.java @@ -0,0 +1,15 @@ +package com.example.springhw4.controller; + +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 adminP(){ + return "admin Controller"; + } +} diff --git a/src/main/java/com/example/springhw4/controller/JoinController.java b/src/main/java/com/example/springhw4/controller/JoinController.java new file mode 100644 index 0000000..24bd6a8 --- /dev/null +++ b/src/main/java/com/example/springhw4/controller/JoinController.java @@ -0,0 +1,24 @@ +package com.example.springhw4.controller; + +import com.example.springhw4.dto.JoinDTO; +import com.example.springhw4.service.JoinService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.ResponseBody; + +@Controller +@ResponseBody +@RequiredArgsConstructor +public class JoinController { + + private final JoinService joinService; + + @PostMapping("/join") + public String joinProcess(JoinDTO joinDTO){ + + joinService.joinProcess(joinDTO); + + return "ok"; + } +} diff --git a/src/main/java/com/example/springhw4/controller/MainController.java b/src/main/java/com/example/springhw4/controller/MainController.java new file mode 100644 index 0000000..d12ec55 --- /dev/null +++ b/src/main/java/com/example/springhw4/controller/MainController.java @@ -0,0 +1,32 @@ +package com.example.springhw4.controller; + +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ResponseBody; + +import java.util.Collection; +import java.util.Iterator; + +@Controller +@ResponseBody +public class MainController { + + @GetMapping("/") + public String mainP(){ + + String username = SecurityContextHolder.getContext().getAuthentication().getName(); + + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + Collection authorities = authentication.getAuthorities(); + Iterator iter = authorities.iterator(); + GrantedAuthority auth = iter.next(); + String role = auth.getAuthority(); + + + return "Main Controller" + username + role; + } +} diff --git a/src/main/java/com/example/springhw4/dto/CustomUserDetails.java b/src/main/java/com/example/springhw4/dto/CustomUserDetails.java new file mode 100644 index 0000000..0bd3722 --- /dev/null +++ b/src/main/java/com/example/springhw4/dto/CustomUserDetails.java @@ -0,0 +1,59 @@ +package com.example.springhw4.dto; + +import com.example.springhw4.entity.UserEntity; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.ArrayList; +import java.util.Collection; + +@RequiredArgsConstructor +public class CustomUserDetails implements UserDetails { + + + private final UserEntity userEntity; + + @Override + public Collection getAuthorities() { + Collection collection = new ArrayList<>(); + collection.add(new GrantedAuthority() { + @Override + public String getAuthority() { + return userEntity.getRole(); + } + + }); + + return collection; + } + + @Override + public String getPassword() { + return userEntity.getPassword(); + } + + @Override + public String getUsername() { + return userEntity.getUsername(); + } + + @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/example/springhw4/dto/JoinDTO.java b/src/main/java/com/example/springhw4/dto/JoinDTO.java new file mode 100644 index 0000000..9323266 --- /dev/null +++ b/src/main/java/com/example/springhw4/dto/JoinDTO.java @@ -0,0 +1,12 @@ +package com.example.springhw4.dto; + +import lombok.Getter; +import lombok.Setter; + +@Setter +@Getter +public class JoinDTO { + + private String username; + private String password; +} diff --git a/src/main/java/com/example/springhw4/entity/UserEntity.java b/src/main/java/com/example/springhw4/entity/UserEntity.java new file mode 100644 index 0000000..aa747d0 --- /dev/null +++ b/src/main/java/com/example/springhw4/entity/UserEntity.java @@ -0,0 +1,23 @@ +package com.example.springhw4.entity; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.Getter; +import lombok.Setter; + +@Entity +@Getter +@Setter +public class UserEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private int id; + + private String username; + private String password; + private String role; + +} diff --git a/src/main/java/com/example/springhw4/jwt/JWTFilter.java b/src/main/java/com/example/springhw4/jwt/JWTFilter.java new file mode 100644 index 0000000..c3ec0b4 --- /dev/null +++ b/src/main/java/com/example/springhw4/jwt/JWTFilter.java @@ -0,0 +1,71 @@ +package com.example.springhw4.jwt; + +import com.example.springhw4.dto.CustomUserDetails; +import com.example.springhw4.entity.UserEntity; +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.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@RequiredArgsConstructor +public class JWTFilter extends OncePerRequestFilter { + + private final JWTUtil jwtUtil; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + + //request에서 Authorization 헤더를 찾음 + String authorization = request.getHeader("Authorization"); + + if (authorization == null || !authorization.startsWith("Bearer ")) { + + System.out.println("token null"); + filterChain.doFilter(request, response); + + return; + } + + // Bearer 부분 제거 후 순수 토큰만 획득 + String token = authorization.split(" ")[1]; + + // 토큰 소멸 시간 검증 + if (jwtUtil.isExpired(token)) { + + System.out.println("token expired"); + filterChain.doFilter(request, response); + + // 조건이 해당되면 메소드 종료 (필수) + return; + } + + // 토큰에서 username 과 role 획득 + String username = jwtUtil.getUsername(token); + String role = jwtUtil.getRole(token); + + // userEntity를 생성하여 값 set + UserEntity userEntity = new UserEntity(); + userEntity.setUsername(username); + // 임시 비밀번호 + userEntity.setPassword("temppassword"); + userEntity.setRole(role); + + //UserDetails에 회원 정보 객체 담기 + CustomUserDetails customUserDetails = new CustomUserDetails(userEntity); + + //스프링 시큐리티 인증 토큰 생성 + Authentication authToken = new UsernamePasswordAuthenticationToken(customUserDetails, null, customUserDetails.getAuthorities()); + //세션에 사용자 등록 + SecurityContextHolder.getContext().setAuthentication(authToken); + + filterChain.doFilter(request, response); + + } +} diff --git a/src/main/java/com/example/springhw4/jwt/JWTUtil.java b/src/main/java/com/example/springhw4/jwt/JWTUtil.java new file mode 100644 index 0000000..f406f1e --- /dev/null +++ b/src/main/java/com/example/springhw4/jwt/JWTUtil.java @@ -0,0 +1,47 @@ +package com.example.springhw4.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 SecretKey secretKey; + + public JWTUtil(@Value("${spring.jwt.secret}")String secret) { + + secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), Jwts.SIG.HS256.key().build().getAlgorithm()); + } + + public String getUsername(String token) { + + return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("username", String.class); + } + + public String getRole(String token) { + + return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("role", String.class); + } + + public Boolean isExpired(String token) { + + return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().getExpiration().before(new Date()); + } + + public String createJwt(String username, String role, Long expiredMs) { + + return Jwts.builder() + .claim("username", username) + .claim("role", role) + .issuedAt(new Date(System.currentTimeMillis())) + .expiration(new Date(System.currentTimeMillis() + expiredMs)) + .signWith(secretKey) + .compact(); + } +} diff --git a/src/main/java/com/example/springhw4/jwt/LoginFilter.java b/src/main/java/com/example/springhw4/jwt/LoginFilter.java new file mode 100644 index 0000000..828677f --- /dev/null +++ b/src/main/java/com/example/springhw4/jwt/LoginFilter.java @@ -0,0 +1,64 @@ +package com.example.springhw4.jwt; + +import com.example.springhw4.dto.CustomUserDetails; +import jakarta.servlet.FilterChain; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +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.util.Collection; +import java.util.Iterator; + +@RequiredArgsConstructor +public class LoginFilter extends UsernamePasswordAuthenticationFilter { + + // 검증 담당하는 곳 + private final AuthenticationManager authenticationManager; + private final JWTUtil jwtUtil; + + @Override + public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { + + String username = obtainUsername(request); + String password = obtainPassword(request); + + System.out.println(username); + + UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(username, password, null); + + return authenticationManager.authenticate(authToken); + } + + //로그인 성공시 실행하는 메소드 (여기서 JWT를 발급하면 됨) + @Override + protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authentication) { + + CustomUserDetails customUserDetails = (CustomUserDetails) authentication.getPrincipal(); + + String username = customUserDetails.getUsername(); + + Collection authorities = authentication.getAuthorities(); + Iterator iterator = authorities.iterator(); + GrantedAuthority auth = iterator.next(); + + String role = auth.getAuthority(); + + // jwt 생성 + String token = jwtUtil.createJwt(username, role, 60*60*10L); + + // response 에 담아서 전달 접두사 Bearer 붙여야 함 + response.addHeader("Authorization", "Bearer " + token); + } + + //로그인 실패시 실행하는 메소드 + @Override + protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) { + System.out.println("ㅋ"); + } +} diff --git a/src/main/java/com/example/springhw4/repository/UserRepository.java b/src/main/java/com/example/springhw4/repository/UserRepository.java new file mode 100644 index 0000000..79c0a24 --- /dev/null +++ b/src/main/java/com/example/springhw4/repository/UserRepository.java @@ -0,0 +1,10 @@ +package com.example.springhw4.repository; + +import com.example.springhw4.entity.UserEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserRepository extends JpaRepository { + + Boolean existsByUsername(String username); + UserEntity findByUsername(String username); +} diff --git a/src/main/java/com/example/springhw4/service/CustomUserDetailsService.java b/src/main/java/com/example/springhw4/service/CustomUserDetailsService.java new file mode 100644 index 0000000..085c703 --- /dev/null +++ b/src/main/java/com/example/springhw4/service/CustomUserDetailsService.java @@ -0,0 +1,28 @@ +package com.example.springhw4.service; + +import com.example.springhw4.dto.CustomUserDetails; +import com.example.springhw4.entity.UserEntity; +import com.example.springhw4.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class CustomUserDetailsService implements UserDetailsService { + + private final UserRepository userRepository; + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + + UserEntity userData = userRepository.findByUsername(username); + + if(userData != null){ + return new CustomUserDetails(userData); + } + + return null; + } +} diff --git a/src/main/java/com/example/springhw4/service/JoinService.java b/src/main/java/com/example/springhw4/service/JoinService.java new file mode 100644 index 0000000..f6568cb --- /dev/null +++ b/src/main/java/com/example/springhw4/service/JoinService.java @@ -0,0 +1,37 @@ +package com.example.springhw4.service; + +import com.example.springhw4.dto.JoinDTO; +import com.example.springhw4.entity.UserEntity; +import com.example.springhw4.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class JoinService { + + private final UserRepository userRepository; + private final BCryptPasswordEncoder bCryptPasswordEncoder; + + public void joinProcess(JoinDTO joinDTO){ + + String username = joinDTO.getUsername(); + String password = joinDTO.getPassword(); + + Boolean isExist = userRepository.existsByUsername(username); + + if (isExist){ + + return; + } + + UserEntity data = new UserEntity(); + + data.setUsername(username); + data.setPassword(bCryptPasswordEncoder.encode(password)); // 암호화 진행 후 주입 + data.setRole("ROLE_ADMIN"); // 접두사를 무조건 가져야 함 + + userRepository.save(data); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml deleted file mode 100644 index 9b78d0c..0000000 --- a/src/main/resources/application.yml +++ /dev/null @@ -1,15 +0,0 @@ -spring: - datasource: - url: jdbc:mysql://localhost:3306/your_database_name?useSSL=false&serverTimezone=UTC&characterEncoding=UTF-8 - username: your_username - password: your_password - driver-class-name: com.mysql.cj.jdbc.Driver - - jpa: - hibernate: - ddl-auto: create - show-sql: true - properties: - hibernate: - format_sql: true - dialect: org.hibernate.dialect.MySQL8Dialect \ No newline at end of file