3. security

이상민·2025년 5월 19일
0

Spring Security

기존 스프링 MVC 흐름

Spring Security를 사용하지 않으면, 사용자의 요청은 다음과 같은 순서로 처리된다

요청 → DispatcherServlet → Controller → Service → Repository

여기에는 보안 관련 기능이 없음 . 즉, 누가 요청했는지, 권한이 있는지 등은 고려하지 않고 그냥 요청을 처리한다. 그래서 인증(Authentication)과 인가(Authorization) 같은 기능을 직접 구현해야 함.

Spring Security란?

spring Security는 스프링에서 인증/인가를 처리하는 강력한 보안 프레임워크로 다음과 같은 기능을 제공한다.

  • 로그인 처리 (폼 로그인, OAuth2, JWT 등)

  • 비밀번호 암호화

  • 인증된 사용자 정보 저장 및 관리

  • 인가 처리 (어떤 권한이 있어야 어떤 URL에 접근 가능한지 등)

implementation 'org.springframework.boot:spring-boot-starter-security'

spring security 라이브러리를 추가하면 요청 흐름이 아래처름 바뀐다.

요청 → Security Filter Chain → DispatcherServlet → Controller → ...

Spring Security 인증 흐름 요약 (JWT 예시 기준)

1. 로그인 요청: 사용자가 ID/PW 전송 → 서버는 확인 후 JWT 토큰 발급

2. 클라이언트 저장: 클라이언트(브라우저, 앱 등)는 토큰을 로컬에 저장

3. 요청 시 헤더에 토큰 첨부: ```Authorization: Bearer <JWT 토큰>```

4.필터에서 토큰 검사:

  - 토큰 유효성 검사

  - 토큰 안의 사용자 정보로 Authentication 객체 생성

  - SecurityContext에 저장

5. 인가 처리:사용자 권한이 요청 리소스에 접근 가능한지 판단 후 허용/거부

마무리 정리

개념설명
Authentication“누군지 확인” (로그인, 토큰 검사 등)
Authorization“권한이 있는지 확인” (접근 허용/차단)
Filter요청을 가로채서 보안 처리하는 역할
SecurityContext현재 로그인한 사용자 정보를 저장하는 공간
JWT토큰 기반 인증 시 사용하는 방식 (Spring Security에서 직접 지원하지는 않고 우리가 구현)

Security Config

전체 코드


@Configuration
@RequiredArgsConstructor
public class Securityconfigs {
    private final JwtAuthFilter jwtAuthFilter;
    @Bean//return 하는 객체를 싱글톤으로 만듦
    public SecurityFilterChain myFilter(HttpSecurity httpSecurity) throws Exception {
        //내가 만든 securityFilterChain을 만들어서 리턴
        return httpSecurity
                .cors(cors->cors.configurationSource(corsConfigurationSource()))
                .csrf(AbstractHttpConfigurer::disable)// 보안 공격 csrf 대비 안함 보안 공격을 대비할 수 있는 방안이 많아서 filter에서 비활성
                .httpBasic(AbstractHttpConfigurer::disable)// HttpBasic 비활성화 우리는 토큰 기반의 인증을 할거여서 비활성
                //특정 url 패턴에 대해서는 Authentication 객체를 요구하지 않음(인증처리 제외)
                .authorizeHttpRequests(a-> a.requestMatchers("/member/create","/member/doLogin").permitAll().anyRequest().authenticated())
                .sessionManagement(s->s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))//세션 방식을 사용하지 않겠다는 의미
                .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class) //위 특정 url을 제외하고 사용할 인증 필터를 추가
                .build();
    }

    @Bean
    CorsConfigurationSource corsConfigurationSource(){
        CorsConfiguration configuration = new CorsConfiguration();
        configuration.setAllowedOrigins(Arrays.asList("http://localhost:3000"));
        configuration.setAllowedMethods(Arrays.asList("*")); // 모든 http메서드 허용
        configuration.setAllowedHeaders(Arrays.asList("*")); // 모든 헤더값 허용
        configuration.setAllowCredentials(true);// 자격 증명 허용

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**",configuration);
        return source;
    }
}

SecurityFilterChain

@Bean//return 하는 객체를 싱글톤으로 만듦
    public SecurityFilterChain myFilter(HttpSecurity httpSecurity) throws Exception {
       
        return httpSecurity
                .cors(cors->cors.configurationSource(corsConfigurationSource()))
                .csrf(AbstractHttpConfigurer::disable)
                .httpBasic(AbstractHttpConfigurer::disable)
                .authorizeHttpRequests(a-> a.requestMatchers("/member/create","/member/doLogin").permitAll().anyRequest().authenticated())
                .sessionManagement(s->s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
                .build();
    }
  1. .cors(cors -> cors.configurationSource(corsConfigurationSource()))

    • CORS(Cross-Origin Resource Sharing)는 다른 도메인에서 서버에 요청할 수 있도록 허용하는 정책
    • 프론트엔드가 React (localhost:3000)이고, 백엔드가 Spring (localhost:8080)이라면, 도메인이 다르므로 브라우저가 요청을 차단
  2. .csrf(AbstractHttpConfigurer::disable)

    • CSRF(Cross-Site Request Forgery)는 사용자의 의지와 무관하게 요청을 보내도록 만드는 공격
    • 보통 폼 기반 로그인에서 많이 문제되는데, JWT 기반 인증에서는 토큰 자체에 보안 정보가 담기기 때문에 CSRF 보호가 필요 없음
  3. .httpBasic(AbstractHttpConfigurer::disable)

    • HTTP Basic 인증은 요청할 때마다 브라우저 팝업이 뜨면서 ID/PW를 물어보는 방식.
    • 본 서비스에서는 jwt 기반 인증 방식을 사용하므로 해당 기능을 꺼둠
  4. .authorizeHttpRequests(...)

    a -> a.requestMatchers("/member/create", "/member/doLogin").permitAll()
     .anyRequest().authenticated()
    
    • /member/create, /member/doLogin → 누구나 접근 허용
    • 그 외 모든 요청(anyRequest()) → 인증된 사용자만 접근 허용
  5. .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)

    • 이 설정은 스프링 시큐리티가 동작하기 전에, 우리가 만든 JWT 필터를 먼저 실행하는 기능

CorsConfigurationSource corsConfigurationSource()

configuration.setAllowedOrigins(Arrays.asList("http://localhost:3000")); // 이 도메인만 허용
configuration.setAllowedMethods(Arrays.asList("*")); // GET, POST 등 모든 메서드 허용
configuration.setAllowedHeaders(Arrays.asList("*")); // 모든 헤더 허용
configuration.setAllowCredentials(true); // 인증 정보를 포함한 요청 허용 (ex: 쿠키, Authorization 헤더)

JWT 토큰


jwt 토큰은 header, payload, signature로 구성 되어있다. header는 토큰에 어떤 알고리즘으로 생성 되었는지 타입이 뭔지, payload에는 실질적인 정보가 들어간다. signature에는 payload의 토큰이 우리 서버에서 생성 되었는지 확인하는 정보가 들어간다.(signature는 암호화되어 있다.)

{
  "alg": "HS256",  // 어떤 알고리즘으로 서명했는지
  "typ": "JWT"     // 토큰 타입 (고정적으로 JWT)
}

payload

{
  "sub": "user123",
  "role": "ROLE_USER",
  "exp": 1686200000  // 만료 시간 (Unix timestamp)
}

signature

Signature = HMACSHA256(
  base64UrlEncode(header) + "." + base64UrlEncode(payload),
  secret
)

인코딩 vs 암호화 차이

구분인코딩암호화
목적데이터 표현 변환보안 보호
복호화누구나 가능 (Base64 등)비밀키가 있어야 가능
예시Base64, URL 인코딩AES, RSA, HMAC 등
JWT 적용Header, Payload → 인코딩Signature → 해시 기반 서명

💡 결론:

Header와 Payload는 Base64 인코딩 (누구나 볼 수 있음)

Signature는 서명(Hash) 되어 있어서 위조 방지용
→ JWT는 무결성(내용이 안 바뀌었는지) 확인은 가능하지만 **기밀성(내용을 숨김)**은 제공하지 않음

jwt 흐름

MemberController

    @PostMapping("doLogin")
    public ResponseEntity<?> doLogin(@RequestBody MemberLoginReqDto memberLoginReqDto){
        // email, password 검증
        Member member = memberService.login(memberLoginReqDto);

        //일치할 경우 access 토큰 발행
        String jwtToken = jwtTokenProvider.createToken(member.getEmail(), member.getRole().toString());
        Map<String, Object> loginInfo = new HashMap<>();
        loginInfo.put("id",member.getId());
        loginInfo.put("token",jwtToken);

        return new ResponseEntity<>(loginInfo,HttpStatus.OK);
    }

JwtTokenProvider

@Component
public class JwtTokenProvider {

    private final String secretKey;

    private final int expiration;

    private Key SECRET_KEY;

    public JwtTokenProvider(
            //secretKey와 expiration을 yaml에서 가져옴
            @Value("${jwt.secretKey}") String secretKey,
            @Value("${jwt.expiration}") int expiration
                           ) {
        this.secretKey = secretKey;
        this.expiration = expiration;
        //1. java.util.Base64.getDecoder().decode(secretKey) -> secretKey를 decoding
        //2. new SecretKeySpec(..., SignatureAlgorithm.HS512.getJcaName()); -> 1번의 결과를 '암호화' (복구 불가)
        this.SECRET_KEY = new SecretKeySpec(java.util.Base64.getDecoder().decode(secretKey), SignatureAlgorithm.HS512.getJcaName());
    }


    public String createToken(String email, String role){
        //claims: jwt의 payload에 해당하는 부분
        //.setSubject(): 대표값 세팅 여기서는 email을 대푯값으로 사용
        Claims claims = Jwts.claims().setSubject(email);
        claims.put("role",role);
        Date now = new Date();
        String token = Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(now) //발행시간 세팅
                .setExpiration(new Date(now.getTime() + expiration*60*1000L)) // 만료일자 세팅
                .signWith(SECRET_KEY) // 서명: 암호화 시킨 secret_key 값으로 서명
                .compact();
        return token;
    }
}

JwtAuthFilter


<@Component
public class JwtAuthFilter extends GenericFilter {
    @Value("${jwt.secretKey}")
    private String secretKey;

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        //request에서 jwt를 꺼내서 확인한후 인증 되면 dofilter 인증이 안되면 에러
        // dofilter로 돌아갈때 우리가 만든 토큰이 맞으면 authentication 객체를 만들어야함
        //
        HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest;
        HttpServletResponse httpServletResponse = (HttpServletResponse) servletResponse;
        //토큰 꺼내기
        String token = httpServletRequest.getHeader("Authorization");

        try{
            //토큰이 없는 경우에는 그냥 지나침(authentication)
            if(token!=null){
                //토큰을 넣어서 왔을 때
                if(!token.substring(0,7).equals("Bearer ")){
                    throw new AuthenticationServiceException("Bearer 형식이 아닙니다.");
                }
                String jwtToken = token.substring(7);

                // 토큰 검증 및 claims 추출
                Claims claims = Jwts.parserBuilder()
                        .setSigningKey(secretKey) //
                        .build()
                        .parseClaimsJws(jwtToken)
                        .getBody();
                //Authentication 객체 생성\
                List<GrantedAuthority> authorities = new ArrayList<>();
                authorities.add(new SimpleGrantedAuthority("ROLE_"+claims.get("role")));

                UserDetails userDetails = new User(claims.getSubject(),"", authorities);
                //authentication 객체에는 이메일과 권한이 들어가 있음
                Authentication authentication = new UsernamePasswordAuthenticationToken(userDetails,"",userDetails.getAuthorities());
                //SecurityContextHolder 안에 있는 context 안에 authentication 객체가 있음
                SecurityContextHolder.getContext().setAuthentication(authentication);

            }
            filterChain.doFilter(servletRequest,servletResponse);
        }catch (Exception e){
            e.printStackTrace();
            httpServletResponse.setStatus(HttpStatus.UNAUTHORIZED.value());
            httpServletResponse.setContentType("application/json");
            httpServletResponse.getWriter().write("invalid token");
        }
        //토큰이 있을 때만 검증

    }


}

토큰추출

String token = httpServletRequest.getHeader("Authorization");
  • Http 요청헤더에서 JWT 꺼냄

토큰 유효성 검사

Claims claims = Jwts.parserBuilder()
    .setSigningKey(secretKey)
    .build()
    .parseClaimsJws(jwtToken)
    .getBody();

인증객체 생성

UserDetails userDetails = new User(claims.getSubject(), "", authorities);
Authentication authentication = new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication);
profile
잘하자

0개의 댓글