의존성 추가
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
- jwt.secret = ${JWT_SECRET} 쓸 때 방식
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);
}
}
- JwtAuthFilter 지금 코드에서 실무적으로 자주 추가하는 개선 3가지
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();
}
}
포스트맨으로 확인