diff --git a/.idea/checkstyle-idea.xml b/.idea/checkstyle-idea.xml new file mode 100644 index 0000000..6373743 --- /dev/null +++ b/.idea/checkstyle-idea.xml @@ -0,0 +1,16 @@ + + + + 12.1.2 + JavaOnly + true + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml index 35eb1dd..bb38a43 100644 --- a/.idea/vcs.xml +++ b/.idea/vcs.xml @@ -2,5 +2,6 @@ + \ No newline at end of file diff --git a/week10/AuthController.java b/week10/AuthController.java new file mode 100644 index 0000000..9535163 --- /dev/null +++ b/week10/AuthController.java @@ -0,0 +1,86 @@ +package io.api.oauth2; + +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; +import java.util.UUID; + +@RestController +@RequestMapping("/api/v1/auth") +@RequiredArgsConstructor +public class AuthController { + + private final JwtProvider jwtProvider; + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + + // 회원가입 + @PostMapping("/register") + public ResponseEntity register(@RequestBody Map userInfo) { + String email = userInfo.get("email"); + String password = userInfo.get("password"); + String userName = userInfo.get("userName"); + + // 이메일 중복 검사 + if (userRepository.findByUserEmail(email).isPresent()) { + return ResponseEntity.badRequest().body(Map.of("error", "Email already exists")); + } + + // 사용자 생성 + UserEntity newUser = new UserEntity( + UUID.randomUUID().toString(), // userId + userName, // userName + email, // userEmail + passwordEncoder.encode(password), // password + "USER" // userRole - 기본값 USER + ); + + userRepository.save(newUser); + + return ResponseEntity.ok(Map.of( + "message", "User registered successfully", + "email", email + )); + } + + // 로그인 (Basic 인증 헤더로 요청 → Bearer 토큰 발급) + @PostMapping("/login") + public ResponseEntity login() { + // Spring Security가 Basic 인증을 이미 처리했으므로 + // SecurityContextHolder에서 인증된 사용자 정보를 가져옴 + Authentication authentication = + org.springframework.security.core.context.SecurityContextHolder.getContext().getAuthentication(); + + if (authentication == null || !authentication.isAuthenticated()) { + return ResponseEntity.status(401).body(Map.of( + "error", "Invalid credentials" + )); + } + + String email = authentication.getName(); + + try { + // 사용자 조회 + UserEntity user = userRepository.findByUserEmail(email) + .orElseThrow(() -> new RuntimeException("User not found")); + + // JWT 토큰 생성 + String token = jwtProvider.createToken(user.getUserId()); + + return ResponseEntity.ok(Map.of( + "token", token, + "tokenType", "Bearer", + "userId", user.getUserId(), + "email", user.getUserEmail() + )); + } catch (Exception e) { + return ResponseEntity.status(401).body(Map.of( + "error", "Invalid credentials" + )); + } + } +} \ No newline at end of file diff --git a/week10/BasicAuthLoggingFilter.java b/week10/BasicAuthLoggingFilter.java new file mode 100644 index 0000000..f2b4f40 --- /dev/null +++ b/week10/BasicAuthLoggingFilter.java @@ -0,0 +1,28 @@ +package io.api.oauth2; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Component +public class BasicAuthLoggingFilter extends OncePerRequestFilter { + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) + throws ServletException, IOException { + + String authHeader = request.getHeader("Authorization"); + if (authHeader != null && authHeader.startsWith("Basic ")) { + System.out.println("🔐 Basic Authorization Header: " + authHeader); + } + + filterChain.doFilter(request, response); + } +} diff --git a/week10/BasicAuthSecurityConfig.java b/week10/BasicAuthSecurityConfig.java new file mode 100644 index 0000000..9255801 --- /dev/null +++ b/week10/BasicAuthSecurityConfig.java @@ -0,0 +1,77 @@ +package io.api.oauth2; + +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.Customizer; +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.UsernamePasswordAuthenticationFilter; +import org.springframework.security.web.authentication.www.BasicAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class BasicAuthSecurityConfig { + + private final JwtAuthenticationFilter jwtAuthenticationFilter; + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + .csrf(AbstractHttpConfigurer::disable) + .httpBasic(Customizer.withDefaults()) // Basic 인증 활성화 + .sessionManagement(session -> session + .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(authorize -> authorize + .requestMatchers("/api/v1/auth/register").permitAll() // 회원가입은 인증 불필요 + .requestMatchers("/api/v1/auth/login").authenticated() // 로그인은 Basic 인증 필요 + .requestMatchers("/api/v1/user/**").authenticated() // 프로필 조회는 인증 필요 + .anyRequest().authenticated() + ) + // JWT 필터 추가 (Bearer 토큰 처리) + .addFilterBefore(basicAuthLoggingFilter(), BasicAuthenticationFilter.class) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception { + return config.getAuthenticationManager(); + } + + @Bean + public BasicAuthLoggingFilter basicAuthLoggingFilter() { + return new BasicAuthLoggingFilter(); + } + + @Bean + protected CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + configuration.addAllowedOrigin("*"); + configuration.addAllowedHeader("*"); + configuration.addAllowedMethod("*"); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + + return source; + } +} diff --git a/week10/CustomUserDetailsService.java b/week10/CustomUserDetailsService.java new file mode 100644 index 0000000..1c6967d --- /dev/null +++ b/week10/CustomUserDetailsService.java @@ -0,0 +1,27 @@ +package io.api.oauth2; + +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.userdetails.User; +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 userEntity = userRepository.findByUserEmail(username) + .orElseThrow(() -> new UsernameNotFoundException("User not found: " + username)); + + return User.builder() + .username(userEntity.getUserEmail()) + .password(userEntity.getPassword()) // 암호화된 비밀번호가 저장되어 있어야 함 + .roles(userEntity.getUserRole()) // DB에서 role 가져오기 + .build(); + } +} diff --git a/week10/JwtAuthenticationFilter.java b/week10/JwtAuthenticationFilter.java new file mode 100644 index 0000000..e99dc25 --- /dev/null +++ b/week10/JwtAuthenticationFilter.java @@ -0,0 +1,74 @@ +package io.api.oauth2; + +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.AbstractAuthenticationToken; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + private final JwtProvider jwtProvider; + private final UserRepository userRepository; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + try { + String token = parseToken(request); + if (token != null) { + String userId = jwtProvider.validate(token); + + if(userId == null) { + filterChain.doFilter(request, response); + return; + } + //fintByUserId로 User의 ID를 가져옴 + UserEntity userEntity = userRepository.findByUserId(userId); + // User 의 권한을 가져와야함 + String role = userEntity.getUserRole(); + //ROLE_USER, ROLE_ADMIN, ROLE_DEV + List authorities = new ArrayList<>(); + authorities.add(new SimpleGrantedAuthority(role)); + + SecurityContext securityContext = SecurityContextHolder.createEmptyContext(); + AbstractAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(userId, null, authorities); + authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + //context에 토큰 값을 담아줌 + securityContext.setAuthentication(authenticationToken); + //만든 context 등록 + SecurityContextHolder.setContext(securityContext); + }else { //authorization이 없거나 토큰이 없으면 바로 다음 필터 진행 + filterChain.doFilter(request, response); + return; + } + }catch (Exception e) { + e.printStackTrace(); + } + filterChain.doFilter(request, response); + } + + private String parseToken(HttpServletRequest request) { + //헤더에 있는 Authrization을 가져옴 + String bearerToken = request.getHeader("Authorization"); + //가져온 토큰이 널이아니고 Bearer로 시작하면 bearer문자열 값 짜르고 리턴 + if (bearerToken != null && bearerToken.startsWith("Bearer ")) { + return bearerToken.substring(7); + }else { + return null; + } + } +} diff --git a/week10/JwtProvider.java b/week10/JwtProvider.java new file mode 100644 index 0000000..26cb0da --- /dev/null +++ b/week10/JwtProvider.java @@ -0,0 +1,76 @@ +package io.api.oauth2; + +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.security.Keys; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.nio.charset.StandardCharsets; +import java.security.Key; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Date; + +@Component +public class JwtProvider { + // 롬복의 value아닌 빈스 팩토리의 벨류 +// 이게 프로퍼티에 있는 시크릿키를 가져오는 어노테이션 + @Value("${secret-key}") + private String secretKey; + public String createToken(String userId){ + Date expiredDate = Date.from(Instant.now().plus(1, ChronoUnit.HOURS)); + //Key 설정을 해줘야함 + //jwt는 헤더, 페이로드, 시그니처로 구성되어있고 + // 헤더는 시그니쳐랑 페이로드를 조합해서 알아서 생성된다고 했음 + // 그래서 헤더는? 만들필요가없다~ + // 그럼 만들어야될거? + // payload에 실어 보낼 값과 시그니쳐를 만들면됨 + // secretKey를 바탕으로 HMAC-SHA 키 -> 시그니쳐 생성. + Key key = Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8)); + //jwt 만들고 반환. + return Jwts.builder() + //JWT 생성 + //자 시작은 무슨 키로했는지 내가 쓴 알고리즘이 뭔지 + // 이걸로 signWith로 시작하는게 규칙 + .signWith(key, SignatureAlgorithm.HS256) + // 다음 내가 jwt토큰에 실어서 보낼 정보 입력. + // jwt토큰은 탈취당하는 위험을 + // 토큰의 파기. 리프레시로 결정한다. + // 일단 setExpiration을 쓰고(아까 만들어논 expiredDate를 설정. + // 일단 userId를 실어 보냄 + .setSubject(userId) + .setIssuedAt(new Date()) + .setExpiration(expiredDate) + .compact(); + } + // 여기까지 jwt를 만들었음 + // 검증을 해야되지 -> 검증 메서드를 만들자. + // 검증은 보통 벨리데이트 라고 + // jwt 검증 메서드 + public String validate(String jwt){ + String subject; + //Signature 생성 + Key key = Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8)); + try { + subject = Jwts.parserBuilder() + .setSigningKey(key) + .build() + // parseClaimsJwt와 Jws가 있는데 + // 여기보면 jwt는 받아오는 인자가 object랑 key + // jws는 key만 + // 우리는 파라미터 인자가 jwt하나 + // 그니까 jws를 사용해야함 + .parseClaimsJws(jwt) + .getBody() + .getSubject(); + }catch (Exception e){ + // 로그관리해주는 어노테이션 추가해서 로그관리 + // @Slf4j 추가 그리고 getMesage()로 바꿈 + // 로그의 레벨설정 + e.printStackTrace(); + return null; + } + return subject; + } +} \ No newline at end of file diff --git a/week10/OAuth2Application.java b/week10/OAuth2Application.java new file mode 100644 index 0000000..47479fd --- /dev/null +++ b/week10/OAuth2Application.java @@ -0,0 +1,11 @@ +package io.api.oauth2; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class OAuth2Application { + public static void main(String[] args) { + SpringApplication.run(OAuth2Application.class, args); + } +} diff --git a/week10/UserEntity.java b/week10/UserEntity.java new file mode 100644 index 0000000..e4835e3 --- /dev/null +++ b/week10/UserEntity.java @@ -0,0 +1,32 @@ +package io.api.oauth2; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Entity(name= "user") +@Table(name = "user") +public class UserEntity { + @Id + @Column(name="user_id") + private String userId; + + @Column(name = "user_name") + private String userName; + + @Column(name = "user_email") + private String userEmail; + + @Column(name = "password") + private String password; + + @Column(name = "user_role") + private String userRole; +} \ No newline at end of file diff --git a/week10/UserProfileController.java b/week10/UserProfileController.java new file mode 100644 index 0000000..2731c2b --- /dev/null +++ b/week10/UserProfileController.java @@ -0,0 +1,38 @@ +package io.api.oauth2; + +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; + +@RestController +@RequestMapping("/api/v1/user") +@RequiredArgsConstructor +public class UserProfileController { + + private final UserRepository userRepository; + + // Bearer 토큰으로만 접근 가능한 프로필 조회 + @GetMapping("/profile") + public ResponseEntity getProfile(Authentication authentication) { + // JWT 토큰에서 userId 추출 (JwtAuthenticationFilter에서 설정됨) + String userId = authentication.getName(); + + UserEntity user = userRepository.findByUserId(userId); + + if (user == null) { + return ResponseEntity.notFound().build(); + } + + return ResponseEntity.ok(Map.of( + "userId", user.getUserId(), + "email", user.getUserEmail(), + "userName", user.getUserName(), + "role", user.getUserRole() + )); + } +} \ No newline at end of file diff --git a/week10/UserRepository.java b/week10/UserRepository.java new file mode 100644 index 0000000..363980d --- /dev/null +++ b/week10/UserRepository.java @@ -0,0 +1,12 @@ +package io.api.oauth2; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface UserRepository extends JpaRepository { + UserEntity findByUserId(String userId); + Optional findByUserEmail(String userEmail); +}