1. 실습
1) config 폴더
① SecurityConfig.java
package com.metacoding.springv2._core.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import com.metacoding.springv2._core.filter.JwtAuthorizationFilter;
@Configuration
public class SecurityConfig {
@Bean
public BCryptPasswordEncoder encode() {
return new BCryptPasswordEncoder();
}
// 시큐리티 필터 등록
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
// 인증/권한 주소 커스터마이징
http.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/api/**").authenticated()
.anyRequest().permitAll()
);
// UsernamePasswordAuthenticationFilter 비활성화
// 폼 로그인 비활성화 ( POST : x-www-form urlencoded : username, password )
http.formLogin(f -> f.disable());
// 베이직 인증 비활성화 (request 할 때마다 username, password를 요구)
http.httpBasic(b -> b.disable());
// input에 csrf 토큰 받는 것을 비활성화
http.csrf(c -> c.disable());
// 인증 필터를 변경 (before/after 상관 없음!)
http.addFilterBefore(new JwtAuthorizationFilter(), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}2) filter 폴더
① JwtAuthorizationFilter.java
package com.metacoding.springv2._core.filter;
import java.io.IOException;
import org.springframework.web.filter.OncePerRequestFilter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
// 인가 필터 (로그인하는 필터 아님! -> 인가필터)
public class JwtAuthorizationFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
filterChain.doFilter(request, response);
}
}3) auth 폴더
① AuthController.java
package com.metacoding.springv2.auth;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import jakarta.servlet.http.HttpSession;
import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor
@RestController
public class AuthController {
private final AuthService authService;
private final HttpSession session;
@PostMapping("/login")
public String login(@RequestBody AuthRequest.LoginDTO reqDTO){ // @RequestBody -> JSON 데이터 파싱
authService.로그인(reqDTO);
session.setAttribute(
HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY,
SecurityContextHolder.getContext()
);
return "login까지 오나?";
}
@GetMapping("/health")
public String healthCheck() {
return "health ok";
}
}② AuthRequest.java
package com.metacoding.springv2.auth;
import com.metacoding.springv2.user.User;
import jakarta.validation.constraints.*;
import lombok.Data;
public class AuthRequest {
@Data
public static class JoinDTO {
private String username;
private String password;
private String email;
}
@Data
public static class LoginDTO {
private String username;
private String password;
}
}③ AuthService.java
package com.metacoding.springv2.auth;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
import com.metacoding.springv2._core.handler.ex.Exception401;
import com.metacoding.springv2.auth.AuthRequest.LoginDTO;
import com.metacoding.springv2.user.User;
import com.metacoding.springv2.user.UserRepository;
import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor
@Service
public class AuthService {
private final UserRepository userRepository;
private final BCryptPasswordEncoder bCryptPasswordEncoder;
public void 로그인(LoginDTO reqDTO) {
// 1. UserRepository에서 username 확인
User findUser = userRepository.findByUsername(reqDTO.getUsername())
.orElseThrow(() -> new Exception401("유저네임을 찾을 수 없습니다."));
// 2. password를 hash해서 비교 검증
boolean isSamePassword = bCryptPasswordEncoder.matches(reqDTO.getPassword(), findUser.getPassword());
if (!isSamePassword)
throw new Exception401("비밀번호가 틀렸습니다.");
// 3. Authentication 객체 만들기
Authentication authentication = new UsernamePasswordAuthenticationToken(findUser, null, findUser.getAuthorities());
// 4. SecurityContextHolder에 저장
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}4) user 폴더
① User.java
package com.metacoding.springv2.user;
import java.sql.Timestamp;
import java.util.ArrayList;
import java.util.Collection;
import org.hibernate.annotations.CreationTimestamp;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
@NoArgsConstructor
@Getter
@Entity
@Table(name = "user_tb")
public class User implements UserDetails {
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Id
private Integer id;
@Column(unique = true, length = 20, nullable = false)
private String username;
@Column(length = 60, nullable = false)
private String password;
@Column(length = 30, nullable = false)
private String email;
private String roles; // 디폴트값은 USER
@CreationTimestamp
private Timestamp createdAt;
@Builder
public User(Integer id, String username, String password, String email, String roles, Timestamp createdAt) {
this.id = id;
this.username = username;
this.password = password;
this.email = email;
this.roles = roles;
this.createdAt = createdAt;
}
public void update(String email, String password) {
this.email = email;
this.password = password;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> as = new ArrayList<>();
String[] roleList = roles.split(",");
for (String role : roleList) {
as.add(() -> role);
}
return as;
}
}② UserRepository.java
package com.metacoding.springv2.user;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
// findById, findAll, save, deleteById, count, paging
public interface UserRepository extends JpaRepository<User, Integer> {
@Query("select u from User u where u.username = :username")
public Optional<User> findByUsername(@Param("username") String username);
}2. 코드 이해하기
1️⃣ 전체 구조
- /login에서 아이디/비번 검증 →
Authentication생성 →SecurityContextHolder에 넣음 → 그SecurityContext를 HttpSession에 저장
- 이후 /api/ 요청이 들어오면 Spring Security가 세션에서
SecurityContext를 복원해서 “인증됨” 상태로 처리
2️⃣ 파일별 역할 정리
1) SecurityConfig
Spring Security의 “보안 규칙 + 필터 체인” 설정
BCryptPasswordEncoderBean 등록- 회원 비밀번호(해시) 비교할 때
matches()로 검증하기 위한 인코더
- 인가(접근 제어) 규칙
.requestMatchers("/api/**").authenticated()
.anyRequest().permitAll()/api/**는 “로그인(인증)된 사용자만 접근 가능”/login, /health)- 기본 인증 방식들 비활성화
formLogin.disable(): 스프링 기본 로그인 폼/처리 로직 안 씀httpBasic.disable(): 요청마다 브라우저 팝업 뜨는 basic auth 안 씀csrf.disable(): (실습 편의상) CSRF 토큰 요구 안 함
- 커스텀 필터 등록
http.addFilterBefore(new JwtAuthorizationFilter(), UsernamePasswordAuthenticationFilter.class);JwtAuthorizationFilter이지만 실제로는 **“커스텀 인가 필터 자리”**만 잡아둔 상태filterChain.doFilter()만 해서 아무 것도 안 함2) JwtAuthorizationFilter
OncePerRequestFilter 기반의 “요청당 1회 실행되는 필터”
- 현재 코드
filterChain.doFilter(request, response);
- 보통 여기서 하는 일(확장 포인트)
- JWT면
Authorization: Bearer ...토큰 파싱 - 세션이면 “세션이 없을 때 처리/로그/예외 변환” 등 커스텀 가능
3) AuthController
로그인 요청을 받아서 “세션에 SecurityContext를 저장”하는 컨트롤러
POST /loginauthService.로그인(reqDTO)호출해서 인증 성공 시 SecurityContextHolder에 Authentication 저장- 그 다음 세션에 SecurityContext를 직접 저장
- 핵심:
SPRING_SECURITY_CONTEXT_KEY라는 정해진 키로 저장해야 Spring Security가 다음 요청에서 읽어옴 - 즉 “로그인 성공 상태를 세션에 영속화”하는 작업
session.setAttribute( HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, SecurityContextHolder.getContext() );
GET /health- 보안과 무관한 헬스체크 (항상 permitAll)
4) AuthRequest
요청 DTO 모음
LoginDTO { username, password }
JoinDTO는 있지만 현재 흐름에서는 로그인에만 사용
5) AuthService
실제 로그인(인증) 로직
로그인(LoginDTO) 내부 단계- username으로 유저 조회
- 없으면
Exception401("유저네임을 찾을 수 없습니다.")
- 비밀번호 검증
BCryptPasswordEncoder.matches(평문, 해시)로 비교- 실패하면
Exception401("비밀번호가 틀렸습니다.")
- Authentication 생성
- principal:
findUser(UserDetails 구현체) - credentials: null (이미 검증 끝났으니 보통 null)
- authorities: 권한 목록
Authentication authentication =
new UsernamePasswordAuthenticationToken(findUser, null, findUser.getAuthorities());- SecurityContextHolder에 저장
- 여기까지는 “현재 요청 스레드 안에서만 인증된 상태”
- 다음 요청까지 유지하려면 “세션 저장”이 필요하고, 그건 Controller에서 수행 중
SecurityContextHolder.getContext().setAuthentication(authentication);
6) User
엔티티 + Spring Security의 UserDetails 구현체
- DB 테이블:
user_tb
UserDetails구현의 핵심은getAuthorities()
String[] roleList = roles.split(",");
as.add(() -> role);"USER,ADMIN" 형태면 권한 2개 생성"ROLE_USER" 같은 컨벤션도 쓰는데, 지금은 문자열 그대로 권한으로 들어감7) UserRepository
username으로 유저 조회용 JPA Repository
findByUsername로 로그인 시 조회
3️⃣ 실제 동작 순서 (요청 흐름)
A. POST /login 요청이 들어오면
- Spring Security FilterChain 진입
/login은permitAll이라 “로그인 안 해도 접근 가능”
- (커스텀)
JwtAuthorizationFilter실행 → 그냥 통과
AuthController.login()실행
AuthService.로그인()실행- 유저 조회 → 비밀번호 검증 → Authentication 생성 →
SecurityContextHolder저장
- 컨트롤러가 세션에 SecurityContext 저장
SPRING_SECURITY_CONTEXT_KEY로 저장
- 응답 반환
→ 이 시점에 브라우저/클라이언트는 세션 쿠키(JSESSIONID) 를 갖게 되고, 이후 요청에 그 쿠키를 같이 보냄
B. 이후 GET /api/** 요청이 들어오면
- 요청이 들어오면 Spring Security의 기본 필터들 중에서
- 세션에 저장된
SPRING_SECURITY_CONTEXT를 읽어서 SecurityContextHolder에 다시 복원하는 과정이 일어남(표준 동작)
SecurityConfig의 규칙 검사/api/**는.authenticated()필요
- 세션에서 복원된
Authentication이 있으면 → “인증됨” → 컨트롤러까지 통과
- 없으면 → “미인증” → 401/리다이렉트 등이 발생 (설정에 따라 다름. 지금은 폼로그인/베이직 꺼서 보통 401 계열로 떨어지는 흐름)
4️⃣ 이 코드의 핵심 포인트 3개
SecurityContextHolder저장만으로는 “다음 요청”까지 인증이 유지되지 않는다- 그래서
HttpSession에SPRING_SECURITY_CONTEXT_KEY로 넣는 게 핵심
JwtAuthorizationFilter는 현재 “자리만 있음”- 이름은 JWT지만 실제 구현이 없어서 인증/인가에 영향 없음
- 나중에 토큰 기반으로 바꾸려면 여기에서 토큰 검증 후
SecurityContextHolder세팅하는 식으로 확장
/api/**만 인증 요구/login은permitAll이라 로그인 엔드포인트 접근이 가능하고, 그 내부에서 세션 인증을 세팅
Share article