diff --git a/week09/keyword/keyword.md b/week09/keyword/keyword.md new file mode 100644 index 0000000..a055c1a --- /dev/null +++ b/week09/keyword/keyword.md @@ -0,0 +1,21 @@ +- **Spring Security** + + Spring Security는 스프링 애플리케이션에서 인증과 인가를 담당하는 보안 프레임워크로, 로그인·로그아웃 처리, 사용자 검증, URL 접근 권한 관리 등을 자동으로 처리해 주는 역할을 한다. 스프링 시큐리티는 모든 HTTP 요청을 컨트롤러에 전달하기 전에 보안 필터 체인을 통해 검사하며, 이 과정에서 아이디·비밀번호가 올바른지 확인하고, 인증된 사용자만 특정 URL을 접근하도록 제한한다. + +- **인증(Authentication)과 인가(Authorization)** + + 인증(Authentication)은 사용자가 누구인지 확인하는 과정이며, 보통 아이디와 비밀번호를 이용해 요청을 보낸 사람이 실제 계정의 주인인지 검증한다. 스프링 시큐리티에서는 이 과정에서 DB에서 사용자를 조회하고, 암호화된 비밀번호와 입력값이 일치하는지 확인한 뒤 성공하면 해당 사용자 정보를 SecurityContext에 저장해 이후 요청에서도 “로그인된 사용자”로 인식하게 만든다. + + 인가(Authorization)는 인증이 끝난 사용자가 어떤 기능이나 자원에 접근할 수 있는지 판단하는 절차이다. 예를 들어 일반 사용자는 기본 페이지만 접근할 수 있고, 관리자만 관리자 페이지에 접근하도록 제한하는 것이 인가에 해당한다. 스프링 시큐리티에서는 SecurityConfig를 통해 특정 URL에 필요한 권한을 지정하여 접근을 제어한다. + +- **세션과 토큰** + + 세션(Session) 방식은 사용자가 로그인하면 서버가 사용자 정보를 메모리에 저장해 두고, 브라우저에게는 JESSIONID라는 쿠키만 전달한다. 이후 사용자가 요청을 보낼 때 이 쿠키가 자동으로 포함되며, 서버는 이 쿠키를 통해 어떤 사용자인지 식별한다. 즉, 로그인 상태를 서버가 직접 관리하는식이다. 다만 서버가 모든 사용자의 세션 정보를 보관해야 하므로 사용자가 많아질수록 서버 부담이 커지고 확장성이 떨어진다. + + 반면 토큰(Token, 특히 JWT) 방식은 서버가 사용자를 인증한 뒤 세션을 저장하지 않고, 사용자 정보를 담은 토큰을 만들어 클라이언트에게 전달한다. 이후 요청마다 클라이언트가 이 토큰을 헤더에 실어 보내고, 서버는 토큰이 유효한지만 확인해 사용자를 식별한다. 즉, 로그인 상태를 서버가 아닌 클라이언트가 보관식이다. + +- **액세스 토큰(Access Token)과 리프레시 토큰(Refresh Token)** + + 액세스 토큰(Access Token)과 리프레시 토큰(Refresh Token)은 JWT 기반 인증 방식에서 사용자의 로그인 상태를 유지하기 위해 함께 사용되는 두 종류의 토큰이다. 액세스 토큰은 짧은 기간동안만 유효한 인증수단으로, 사용자가 API를 호출할 때마다 Authorization 헤더에 담아 서버로 전달하며, 서버는 이 토큰을 검증해 요청을 허용한다. 액세스 토큰의 유효 기간이 짧은 이유는 토큰이 유출되더라도 피해를 최소화하기 위함이다. 한편 리프레시 토큰은 더 긴 기간동안 유지되는 갱신용 토큰으로, 액세스 토큰이 만료되었을 때 새로운 액세스 토큰을 재발급받기 위해 사용된다. 리프레시 토큰은 보통 DB나 Redis에 저장해 관리하며, 유효한 리프레시 토큰을 가진 사용자만 계속해서 인증을 연장할 수 있게 된다. + + \ No newline at end of file diff --git a/week09/mission/mission.md b/week09/mission/mission.md new file mode 100644 index 0000000..1bbb0b9 --- /dev/null +++ b/week09/mission/mission.md @@ -0,0 +1,314 @@ +공통 + +```java +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + runtimeOnly 'com.h2database:h2' + + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' + + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0' +} + +``` + +```java +@Entity +public class Member { + + @Id @GeneratedValue + private Long id; + + @Column(unique = true, nullable = false) + private String username; + + @Column(nullable = false) + private String password; + + private String role; + +} +``` + +```java +public interface MemberRepository extends JpaRepository { + Optional findByUsername(String username); +} +``` + +```java +@Service +@RequiredArgsConstructor +public class MemberService { + + private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; + + public Member signup(String username, String rawPassword) { + if (memberRepository.findByUsername(username).isPresent()) { + throw new IllegalArgumentException("이미 존재하는 아이디"); + } + Member m = new Member(); + m.setUsername(username); + m.setPassword(passwordEncoder.encode(rawPassword)); + m.setRole("ROLE_USER"); + return memberRepository.save(m); + } + + public Member findByUsername(String username) { + return memberRepository.findByUsername(username) + .orElseThrow(() -> new UsernameNotFoundException("유저 없음")); + } +} + +``` + +```java +@Service +@RequiredArgsConstructor +public class CustomUserDetailsService implements UserDetailsService { + + private final MemberService memberService; + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + Member m = memberService.findByUsername(username); + return User.builder() + .username(m.getUsername()) + .password(m.getPassword()) + .roles(m.getRole().replace("ROLE_", "")) + .build(); + } +} + +``` + +1. 세션 기반 + +```java +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final CustomUserDetailsService customUserDetailsService; + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .csrf(csrf -> csrf.disable()) + .requestMatchers("/signup", "/login", "/h2-console/**").permitAll() + .anyRequest().authenticated() + ) + .userDetailsService(customUserDetailsService) + .formLogin(form -> form + .loginPage("/login") + .loginProcessingUrl("/login") + .defaultSuccessUrl("/home", true) + .permitAll() + ) + .logout(logout -> logout + .logoutUrl("/logout") + .logoutSuccessUrl("/login?logout") + ) + .sessionManagement(session -> session + .sessionCreationPolicy(SessionCreationPolicy.ALWAYS) + ); + + http.headers(headers -> headers.frameOptions(frame -> frame.disable())); + + return http.build(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} + +``` + +```java +@Controller +@RequiredArgsConstructor +public class AuthController { + + private final MemberService memberService; + + @GetMapping("/signup") + public String signupForm() { + return "signup"; + } + + @PostMapping("/signup") + public String signup(@RequestParam String username, + @RequestParam String password) { + memberService.signup(username, password); + return "redirect:/login"; + } + + @GetMapping("/login") + public String loginForm() { + return "login"; + } + + @GetMapping("/home") + @ResponseBody + public String home(Authentication auth) { + return "안녕, " + auth.getName() + " (세션 로그인 성공)"; + } +} + +``` + +2JWT기반 로그인 + +```java +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final CustomUserDetailsService customUserDetailsService; + private final JwtFilter jwtFilter; + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .csrf(csrf -> csrf.disable()) + .sessionManagement(sm -> sm + .sessionCreationPolicy(SessionCreationPolicy.STATELESS) + ) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/api/auth/signup", "/api/auth/login", + "/v3/api-docs/**", "/swagger-ui/**").permitAll() + .anyRequest().authenticated() + ) + .userDetailsService(customUserDetailsService); + + http.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} + +``` + +```java +@Component +public class JwtUtil { + + @Value("${jwt.secret}") + private String secretKey; + + private final long EXPIRATION = 1000 * 60 * 60; + + public String generateToken(String username) { + Date now = new Date(); + Date exp = new Date(now.getTime() + EXPIRATION); + + return Jwts.builder() + .setSubject(username) + .setIssuedAt(now) + .setExpiration(exp) + .signWith(Keys.hmacShaKeyFor(secretKey.getBytes()), SignatureAlgorithm.HS256) + .compact(); + } + + public String getUsername(String token) { + return Jwts.parserBuilder() + .setSigningKey(Keys.hmacShaKeyFor(secretKey.getBytes())) + .build() + .parseClaimsJws(token) + .getBody() + .getSubject(); + } +} + +``` + +```java +@Component +@RequiredArgsConstructor +public class JwtFilter extends OncePerRequestFilter { + + private final JwtUtil jwtUtil; + private final CustomUserDetailsService customUserDetailsService; + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) + throws ServletException, IOException { + + String authHeader = request.getHeader("Authorization"); + + if (authHeader != null && authHeader.startsWith("Bearer ")) { + String token = authHeader.substring(7); + + try { + String username = jwtUtil.getUsername(token); + UserDetails userDetails = customUserDetailsService.loadUserByUsername(username); + + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken( + userDetails, null, userDetails.getAuthorities() + ); + SecurityContextHolder.getContext().setAuthentication(authentication); + } catch (Exception e) { + } + } + + filterChain.doFilter(request, response); + } +} + +``` + +```java +@RestController +@RequestMapping("/api/auth") +@RequiredArgsConstructor +public class AuthApiController { + + private final MemberService memberService; + private final AuthenticationManagerBuilder authManagerBuilder; + private final JwtUtil jwtUtil; + + @PostMapping("/signup") + public ResponseEntity signup(@RequestBody LoginRequest req) { + Member m = memberService.signup(req.username(), req.password()); + return ResponseEntity.ok(new SignupResponse(m.getId(), m.getUsername())); + } + + @PostMapping("/login") + public ResponseEntity login(@RequestBody LoginRequest req) { + UsernamePasswordAuthenticationToken authToken = + new UsernamePasswordAuthenticationToken(req.username(), req.password()); + + Authentication auth = authManagerBuilder.getObject().authenticate(authToken); + + String token = jwtUtil.generateToken(req.username()); + return ResponseEntity.ok(new LoginResponse(token)); + } + + @GetMapping("/me") + public ResponseEntity me(Authentication auth) { + return ResponseEntity.ok("현재 로그인: " + auth.getName()); + } + + public record LoginRequest(String username, String password) {} + public record SignupResponse(Long id, String username) {} + public record LoginResponse(String token) {} +} + +``` \ No newline at end of file