의존성 추가

plugins {
	id 'java'
	id 'org.springframework.boot' version '3.5.10'
	id 'io.spring.dependency-management' version '1.1.7'
}

group = 'com.spring_security'
version = '0.0.1-SNAPSHOT'
description = 'spring _security'

java {
	toolchain {
		languageVersion = JavaLanguageVersion.of(17)
	}
}

configurations {
	compileOnly {
		extendsFrom annotationProcessor
	}
}

repositories {
	mavenCentral()
}

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-security'
	implementation 'org.springframework.boot:spring-boot-starter-validation'
	implementation 'org.springframework.boot:spring-boot-starter-web'
	compileOnly 'org.projectlombok:lombok'
	developmentOnly 'org.springframework.boot:spring-boot-devtools'
	annotationProcessor 'org.projectlombok:lombok'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
	testImplementation 'org.springframework.security:spring-security-test'
	testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
	
	
	// JWT(추가)
  implementation 'io.jsonwebtoken:jjwt-api:0.12.5'
  runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.5'
  runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.5'
}

tasks.named('test') {
	useJUnitPlatform()
}

application.properties

spring.application.name=spring_security
server.port=8081

# JWT Option
# JWT 서명(Signature) 을 만들 때 쓰는 대칭키
# 서버만 알고 있어야 함.
# 토큰 발급 시 서명, 요청 시 토큰 검증
jwt.secret=Z9x1Q2w3E4r5T6y7U8i9O0pAaBbCcDdEeFfGgHhIiJjKkLl
# 액세스 토큰 유효 시간
jwt.access-token-exp-min=30

# 시큐리티 디버그 로그 찍어줌
logging.level.org.springframework.security=DEBUG

SecurityConfig

package com.spring_security.auth.config;

import com.spring_security.auth.jwt.JwtAuthFilter;
import com.spring_security.auth.jwt.JwtProvider;
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.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.*;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

// 스프링 설정 클래스, 클래스 안의 @Bean들을 스프링이 등록함.
@Configuration
// 스프링 시큐리티 기능을 활성화하고 아래의 설정들을 적용
@EnableWebSecurity
public class SecurityConfig {

	// 핵심
	// SecurityFilterChain: 보안 필터 규칙 세트
	// HttpSecurity로 어떤 요청을 허용/차단할지, 어떤 인증 방식을 쓸지 설정
	// JwtAuthFilter를 여기에 끼워 넣는 구조
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http, JwtAuthFilter jwtAuthFilter) throws Exception {
        return http
        		// CSRF 끄기
        		// csrf: 인증된 사용자가 자신의 의지와 무관하게 공격자가 의도한 악성 요청(데이터 수정, 삭제 등)을
        		// 웹 애플리케이션에 전송하게 만드는 공격 기법으로 세션/쿠키 기반 로그인에서 중요함.
        		// JWT를 해더로 보내는 방식이라 보통 꺼둠. 단, 쿠기에 JWT를 넣는 방식이라면 다시 고려해야 함.
                .csrf(csrf -> csrf.disable())
                // 서버가 로그인 상태를 세션으로 저장하지 않겠다는 뜻으로 매 요청마다 토큰(JWT)으로 인증해야 함.
                // JWT 방식의 가장 흔한 설정
                .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                // 기본 로그인 폼 끄기
                // 스프링 시큐리티의 기본 로그인 페이지 기능을 끔
                // React에서 로그인 UI만들거니까 까야 됨.
                .formLogin(form -> form.disable())
                // Basic 인증 끄기
                // Basic: 브라우저 팝업 뜨는 아이디/비번 입력 Basic Auth 기능
                // JWT 쓸땐 보통 꺼둠.
                .httpBasic(basic -> basic.disable())
                // 요청별 접근 규칙
                // /login은 누구나 접근 가능(토큰 없어도 됨)
                // 그 외 모든 요청은 인증 필요(토큰 있어야 통과)
                // authenticated()는 로그인(인증) 했는지만 봄
                // 권한(ROLE)까지 따지려면 hasRole("USER")같은 거 추가해야 함.
                // authorizeHttpRequests(0: 인가 규칙을 정의(이 URL에 이 사용자가 들어와도 되나?)
                // requestMatchers(): 적용 대상 URL을 지정, 여러개도 가능("/login", "/signup")
                // permitAll(): 아무 조건 없이 다 허용
                // anyRequest(): 위에서 지정하지 않은 나머지 모든 요청, 일종의 디폴트 규칙
                // authenticated(): 인증된 사용자만 허용
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/login").permitAll()
                        .anyRequest().authenticated()
                )
                // jwtAuthFilter를 UsernamePasswordAuthenticationFilter보다 먼저 실행
                // 요청이 들어오면 먼저 JWT를 검사해서 토큰 유효라면 시큐리티컨텍트에 인증정보 세팅
                // 그 뒤에 시큐리티가 인증했구나 하고 통과
                .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
                .build();
    }

    // DB 없이 메모리 유저
    // UserDetailsService 시큐리티가 이 username의 사용자 정보 가져와 할때 쓰이는 인터페이스
    // DB가 있다면 보통 userRepositiry로 조회해서 반환
    @Bean
    public UserDetailsService userDetailsService(PasswordEncoder encoder) {
        UserDetails user = User.builder()
                .username("user")
                // BCrypt로 암호화해서 저장
                .password(encoder.encode("1234"))
                // ROLE_USER로 저장됨 내부적으로 ROLE_prefix 붙임.
                .roles("USER")
                .build();
        return new InMemoryUserDetailsManager(user);
    }

    // 비밀번호를 평문으로 비교하면 위험하니 해시로 비교
    // BCryptPasswordEncoder가 가장 흔한 표준
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    // /login에서 authenticate() 쓸 때 필요
    // ex) uthenticationManager.authenticate(new UsernamePasswordAuthenticationToken(id, pw)) 이걸로 시큐리티 방식대로 인증 수행
    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
        return config.getAuthenticationManager();
    }

    // 필터도 Bean으로 등록 (순환참조 끊기)
    // JwtAuthFilter를 스프링 빈으로 등록해서 위의 .addFilterBefore(jwtAuthFilter, ...)에 주입되게 함.
    @Bean
    public JwtAuthFilter jwtAuthFilter(JwtProvider jwtProvider, UserDetailsService userDetailsService) {
        return new JwtAuthFilter(jwtProvider, userDetailsService);
    }
}

AuthController

package com.spring_security.auth.controller;

import java.util.Map;

import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
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 com.spring_security.auth.dto.LoginRequest;
import com.spring_security.auth.jwt.JwtProvider;

@RestController
public class AuthController {

	// AuthenticationManager 로그인할 때 들어온 username, password가 맞는지 검증하는 역할
	// 내부적으로 UserDetailsService + PasswordEncoder 사용해서 확인
    private final AuthenticationManager authenticationManager;
    // 로그인 성공 시 JWT(accessToken) 만들어주는 역할
    private final JwtProvider jwtProvider;

    // 생성자 주입
    // 스프링이 빈을 주입해 줌.
    // final이라 생성자로만 주입 가능.
    // 이 방식이 가장 표준적(필드 주입보다 안정적).
    public AuthController(
            AuthenticationManager authenticationManager,
            JwtProvider jwtProvider
    ) {
        this.authenticationManager = authenticationManager;
        this.jwtProvider = jwtProvider;
    }

    // @RequestBody는 JSON 바디를 LoginRequest로 변환
    @PostMapping("/login")
    public Map<String, String> login(@RequestBody LoginRequest req) {

    	// 인증 시도(아이디/비번 검증)
    	// UsernamePasswordAuthenticationToken(username, password): 이 username, password로 로그인 시도할게 하는 요청 객체 생성
    	// authenticationManager.authenticate(...): 내부에서 UserDetailsService로 username의 유저 조회, PasswordEncoder로 비밀번호 일치하는지 확인
    	//맞으면 성공(Authentication 리턴), 틀리면 예외 발생
        authenticationManager.authenticate(
                new UsernamePasswordAuthenticationToken(
                        req.getUsername(), req.getPassword()
                )
        );

        // JWT 토큰 생성
        // 인증에 성공했으니까 access Token 발급
        //보통 토큰에는 username(주체), 만료시간(exp), roles/authorities(권한)같은 정보가 들어가기도 함(JwtProvider 구현에 따라 다름)
        String token = jwtProvider.createToken(req.getUsername());
        
        // JSON 응답
        return Map.of("accessToken", token);
    }
    
    // 파라미터로 Authentication authentication을 받으면 스프링이 현재 SecurityContext에 들어있는 인증 정보를 주입해줌.
    // authentication.getName()은 보통 username이 나옴.
    // JwtAuthFilter가 토큰 검증 성공 후SecurityContextHolder.getContext().setAuthentication(...)해줬을 때 값이 들어옴.
    // GET로 많이 함.
    @GetMapping("/me")
    public String me(Authentication authentication) {
        return "로그인 사용자: " + authentication.getName();
    }
}

LoginRequest

package com.spring_security.auth.dto;

import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

@Getter
@Setter
// 파라미터가 하나도 없는 기본 생성자 생성
// 언제 쓰냐?
// Jackson (@RequestBody)
// JPA 엔티티
// 프레임워크가 리플렉션으로 객체를 먼저 만들어야 할 때
//public class LoginRequest {
//
//    private String username;
//    private String password;
//
//    public LoginRequest() {
//        // 아무것도 안 함
//    }
//}
@NoArgsConstructor

// 모든 필드를 파라미터로 받는 생성자 생성
// 언제 쓰냐?
// 테스트 코드
// 객체를 직접 new 해서 만들 때
// 값이 확정된 객체를 한 번에 만들고 싶을 때
//public class LoginRequest {
//
//    private String username;
//    private String password;
//
//    public LoginRequest(String username, String password) {
//        this.username = username;
//        this.password = password;
//    }
//}
// @AllArgsConstructor

// 둘다 있을때
//public class LoginRequest {
//
//    private String username;
//    private String password;
//
//    // 기본 생성자
//    public LoginRequest() {
//    }
//
//    // 전체 필드 생성자
//    public LoginRequest(String username, String password) {
//        this.username = username;
//        this.password = password;
//    }
//}
public class LoginRequest {
	
	private String username;
	private String password;

}

JwtAuthFilter

package com.spring_security.auth.jwt;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.*;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

// OncePerRequestFilter: 요청 1번당 필터도 1번만 실행되게 보장하는 편한 필터 베이스 클래스
public class JwtAuthFilter extends OncePerRequestFilter {

	// jwtProvider: 토큰 검증(validate), 토큰에서 username 추출(getUsername) 등을 하는 애
    private final JwtProvider jwtProvider;
    // userDetailsService: username으로 UserDetails(권한 포함) 를 로딩하는 애
    // ex) 너는 InMemoryUserDetailsManager로 “user/1234/ROLE_USER” 들어있지
    private final UserDetailsService userDetailsService;

    // 생성자
    public JwtAuthFilter(JwtProvider jwtProvider, UserDetailsService userDetailsService) {
        this.jwtProvider = jwtProvider;
        this.userDetailsService = userDetailsService;
    }

    // 모든 요청이 들어올 때마다 여기 실행됨
    // request: 들어온 HTTP 요청
    // response: 나갈 HTTP 응답
    // filterChain: “다음 필터/컨트롤러로 넘겨라” 할 때 사용
    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain filterChain
    ) throws ServletException, IOException {

    	// 헤더에서 Authorization 값을 꺼냄.
        String auth = request.getHeader("Authorization");

        // Bearer 토큰 형식인지 체크
        // 헤더가 존재하고, "Bearer "로 시작하면 JWT가 있다고 판단.
        // 이 조건을 통과 못하면 통이 없는 요청이거나 형식이 잘못된 요청이니까 인증 처리 안 하고 그냥 다음으로 넘김.
        if (auth != null && auth.startsWith("Bearer ")) {
        	
        	// 실제 토큰 문자열만 뽑기
        	// "Bearer "는 7글자 (B e a r e r + 공백), 앞의 "Bearer " 제거하고 JWT만 남김.
            String token = auth.substring(7);

            // 토큰 유효성 검증
            // validate()가 보통 하는 일
            // 서명(Signature) 검증: 위조/변조 여부 확인, 만료(exp) 확인: 시간이 지났는지, 토큰 형식이 정상인지
            if (jwtProvider.validate(token)) {
            	// 토큰에서 username 꺼내기
            	// JWT payload의 sub(subject) 같은 곳에 username을 넣어두는 경우가 흔함. getUsername()는 거기서 값을 꺼내는 메서드.
                String username = jwtProvider.getUsername(token);
                // username으로 사용자 정보 로딩
                // 여기서 DB든 메모리든 사용자 정보를 가져옴.
                // 중요 포인트: 권한(authorities)을 얻으려고 이걸 함(ROLE_USER, ROLE_ADMIN 같은 권한 정보)
                // 즉, 토큰에 role을 넣지 않았더라도 서버가 UserDetailsService로 다시 가져와서 권한을 채울 수 있음.
                UserDetails user = userDetailsService.loadUserByUsername(username);

                // 인증된 사요자 객체 만들기
                // sernamePasswordAuthenticationToken의 두 가지 용도
                // 1. 로그인 시도할 때: username/password 넣어서 authenticate()로 보냄
                // 2. 로그인 완료 상태 만들 때: principal(UserDetails) + authorities 넣어서 “이미 인증됨”으로 만듦
                // user : principal(누구냐) → UserDetails
                // null : credentials(비밀번호) → 이미 인증 끝났으니 굳이 안 넣음
                // user.getAuthorities() : 권한 목록
                var authentication =
                        new UsernamePasswordAuthenticationToken(
                                user, null, user.getAuthorities()
                        );

                // SecurityContext에 인증 정보 세팅
                // Spring Security가 “이 요청은 인증된 요청이다”라고 판단하는 근거가 여기서 만들어짐.
                // 이걸 해두면 이후에:.anyRequest().authenticated() 통과
                // 컨트롤러 파라미터로 Authentication 주입 가능
                // @AuthenticationPrincipal 사용 가능
                // 즉 /me에서 public String me(Authentication authentication) 이게 값이 들어오는 이유가 바로 여기임.
                SecurityContextHolder.getContext()
                        .setAuthentication(authentication);
            }
        }

        // 다음 필터/컨트롤러로 넘기기
        // 이 줄을 호출해야 요청이 계속 진행돼서 컨트롤러까지 감.
        // 이 줄이 없으면 요청이 여기서 멈춤.
        filterChain.doFilter(request, response);
    }
}

JwtProvider

package com.spring_security.auth.jwt;

// jjwt 라이브러리(0.12.x)의 핵심 클래스들
// Jwts.builder() : 토큰 만들기
// Jwts.parser() : 토큰 파싱/검증하기
// Keys.hmacShaKeyFor() : HMAC 서명용 키 만들기
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;

// @Component로 빈 등록
// @Value로 application.properties/yml 값 주입
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

// SecretKey : 서명/검증에 사용할 키 타입
// StandardCharsets.UTF_8 : 문자열을 바이트로 변환할 때 인코딩 고정
// Date : issuedAt / expiration 설정용
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;

// JWT를 “발급(createToken)”, “검증(validate)”, “토큰에서 username 뽑기(getUsername)” 하는 유틸 클래스
// 이 클래스를 스프링이 자동으로 Bean으로 등록
// 다른 곳에서 JwtProvider를 주입받아 사용할 수 있게 됨
@Component
public class JwtProvider {
	
	// key: JWT 서명(Sign)과 검증(Verify)에 쓰는 비밀키
	// expMillis : 토큰 만료 시간(밀리초 단위)
    private final SecretKey key;
    private final long expMillis;

    // 생성자: 설정값 주입 + 가공
    // @Value("${jwt.secret}"): application.properties에 있는 jwt.secret 값을 문자열로 주입
    // @Value("${jwt.access-token-exp-min}"): jwt.access-token-exp-min 값을 long으로 주입 (예: 30)
    public JwtProvider(
            @Value("${jwt.secret}") String secret,
            @Value("${jwt.access-token-exp-min}") long expMin
    ) {
    	// 비밀키로 SecretKey 생성
    	// secret 문자열을 UTF-8 바이트 배열로 바꿈
    	// Keys.hmacShaKeyFor(...): HMAC 알고리즘(HS256/HS384/HS512) 서명에 맞는 SecretKey로 변환
    	// 중요: secret이 너무 짧으면 여기서 예외가 나거나(혹은 validate에서 실패) 보안상 위험함으로 HS256 기준 최소 32바이트 이상 권장
        this.key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
        // 만료시간을 “분 → 밀리초”로 변환
        // expMin이 “분” 단위니까 분 × 60초 × 1000ms = 밀리초로 변환
        // ex) 30분이면 30 * 60_000 = 1,800,000ms
        this.expMillis = expMin * 60_000;
    }

    // 토큰 생성
    // now: 현재 시간
    // exp: 현재 시간 + 만료시간 = 토큰 만료 시각
    public String createToken(String username) {
        Date now = new Date();
        Date exp = new Date(now.getTime() + expMillis);

        // Jwts.builder() : JWT 만들기 시작
        return Jwts.builder()
        		// .subject(username) : payload의 sub(subject) 클레임에 username 저장
        		// 이걸 나중에 getUsername()으로 다시 꺼냄
                .subject(username)
                // issuedAt(now) : iat(issued at): 언제 발급됐는지 기록
                .issuedAt(now)
                // .expiration(exp) : exp: 언제 만료되는지 기록
                .expiration(exp)
                // .signWith(key) : 위 payload를 key로 서명해서 위조/변조 방지
                .signWith(key)   // ← 0.12.x 방식
                // .compact() : 최종적으로 xxxxx.yyyyy.zzzzz 형태 문자열(JWT) 생성
                .compact();
    }

    // 토큰 검증
    public boolean validate(String token) {
        try {
        	// Jwts.parser() : JWT 파서 생성
            Jwts.parser()
            		// .verifyWith(key) : 이 key로 서명을 검증하겠다
            		// 토큰이 조작되었거나 다른 키로 서명되면 실패
                    .verifyWith(key)   // ← 0.12.x 핵심
                    // .build() : 파서 빌드
                    .build()
                    // .parseSignedClaims(token) : “서명된 JWT”를 파싱(검증 포함)
                    // 여기서 보통 다음을 검사하게 됨:
                    // 서명 유효한지, 만료됐는지(exp), 형식이 맞는지
                    // 예외 나면 false: 만료됨, 서명 불일치, 토큰 깨짐 등등
                    .parseSignedClaims(token);
            return true;
        } catch (Exception e) {
            return false;
        }
    }

    // username 추출
    // 파서로 토큰을 검증하면서 파싱
    // payload(클레임들) 가져오기
    // payload의 sub 값을 반환
    // .getPayload() : 클레임 맵(내용) 부분
    // .getSubject() : 그중 sub 값 (createToken에서 넣어둔 username)
    // 즉, createToken()에서 .subject(username)로 넣고 getUsername()에서 .getSubject()로 꺼내는 구조
    public String getUsername(String token) {
        return Jwts.parser()
                .verifyWith(key)
                .build()
                .parseSignedClaims(token)
                .getPayload()
                .getSubject();
    }
}

포스트맨으로 확인