Spring Security는 스프링의 보안 Java의 인증과 권한 부여에 중점을 둔 하위 프레임워크이다.
보안 용어
- 접근 주체 (Principal) : 보호된 시스템 리소스에 접근하는 사용자나 프로세스 등, 식별 가능한 모든 행위자.
- 인증 (Authentication) : 접근 주체가 제시한 자격 증명을 통해 그들의 신원이 진짜임을 검증하는 절차.
- 인가 (Authorization) : 인증된 주체가 특정 리소스에 대해 요청한 작업을 수행할 수 있는 권한을 가지고 있는지 여부를 결정하는 과정.
- 권한 (Authority) : 인증된 주체에게 부여된, 특정 리소스에 접근하거나 특정 작업을 수행할 수 있도록 허가된 권리의 범위 또는 수준.
JWT
JWT는 헤더(Header), 페이로드(Payload), 서명(Signature) 세 부분으로 구성됨
이 세 부분은 각각 Base64Url로 인코딩되어 점(.)으로 구분 (예: xxxxx.yyyyy.zzzzz)
- 헤더 (Header)
토큰의 종류(typ)와 어떤 알고리즘(alg)으로 서명되었는지 등, 토큰에 대한 기본 정보 담김.
{
"alg": "HS256",
"typ": "JWT"
}
- 페이로드 (Payload)
토큰에 담아 전달할 실제 데이터(정보)를 담는 부분.
사용자의 아이디(sub), 토큰 만료 시간(exp) 등 '클레임(Claim)'이라 불리는 정보 조각들이 포함되는 곳.
중요: 이 부분은 암호화된 것이 아니라서 누구나 디코딩해서 볼 수 있으므로, 비밀번호 같은 민감한 정보는 절대 넣으면 안 됨
{
"sub": "user123",
"name": "홍길동",
"exp": 1672531199
}
서명 (Signature)
토큰의 위변조 여부를 확인하는 데 사용되는 부분
인코딩된 헤더와 페이로드를 합친 후, 서버만 알고 있는 비밀 키(Secret Key)로 암호화하여 생성
이 서명이 있어야만 토큰이 신뢰성을 가짐
구현 과정
1. build.gradle 에 코드 추가
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-security'
// ... 기존 코드 ...
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'
implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'
}
Spring Security를 위한 의존성 하나, JWT를 위한 의존성 3개를 추가한다.
- jjwt-api : 전체적인 구조
- jjwt-jackson : 헤더와 페이로드의 내용을 JSON으로 변환
- jjwt-impl : 서명 & 최종 JWT를 완성
2. JwtToken DTO 생성
@Builder
@Data
@AllArgsConstructor
public class JwtToken {
private String grantType;
private String accessToken;
private String refreshToken;
}
Lombok 을 사용하는 이유 : JWT 처리 과정에 필요한 수많은 데이터 객체들을 생성하고 관리하는 코드를 획기적으로 줄여줌
- @Data: @Getter, @Setter, @ToString, @EqualsAndHashCode 등 핵심적인 메서드들 전부 자동 생성
- @Builder: 객체 생성을 위한 빌더(Builder) 패턴 코드를 자동 생성, 직관적인 객체 생성을 가능하게 함
- @AllArgsConstructor: 모든 필드를 인자로 받는 생성자를 자동 생성
- grantType : 토큰 유형
- "이 토큰은 어떤 종류의 토큰입니다"를 알려주는 필드.
- JWT에서는 거의 항상 Bearer 라는 값을 사용 (Bearer : 표준 인증 방식)
- accessToken : 접근 토큰
- 실제로 API를 호출하거나 보호된 리소스에 접근할 때 사용하는 단기 유효기간의 토큰
- 클라이언트는 요청을 보낼 때마다 이 토큰을 HTTP 헤더에 담아 보내야 함
- refreshToken : 갱신 토큰
- accessToken이 만료되었을 때, 새로 발급받기 위해 사용하는 장기 유효기간의 토큰
- 이 토큰 덕분에 사용자는 accessToken이 만료될 때마다 다시 로그인할 필요 없이, 자동으로 인증 연장 가능
3.application.properties 에 코드 추가
jwt.secret=apU0yOW7PgV5L5qwE04kNjwms6SmuNbM4HBGUQGvCdpF0Xd1f3aI7QMk59h9
JWT의 서명을 만들고 검증하는 데 사용되는 비밀 키로, 외부 노출 XXX
4. SecurityConfig 파일 생성
우리 서버의 보안 총괄 설정 파일이다.
이 클래스 안에서 로그인 방식, URL별 접근 권한, CORS, CSRF 등 애플리케이션의 모든 보안 규칙을 정의한다.
- CORS (Cross-Origin Resource Sharing) 정책: 외부(프론트엔드)와의 연결 규칙 정의
- CSRF (Cross-Site Request Forgery) 보호 기능: JWT 기반 인증을 사용할 때 일반적으로 따르는 보안 정책 중 하나를 선택
@Configuration // 스프링의 설정 클래스임을 나탄냄
@EnableWebSecurity // 스프링 시큐리티 활성화, 웹 보안 설정을 커스터마이징
@RequiredArgsConstructor // final 필드나 @NonNull 필드에 대한 생성자를 자동으로 생성해주는 Lombok 어노테이션
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.cors(Customizer.withDefaults())
.csrf(AbstractHttpConfigurer::disable)
// 세션 비활성화: JWT를 사용하므로 세션을 STATELESS 상태로 변경
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
// 기본 로그인 및 인증 비활성화: 기본으로 제공되는 formLogin, httpBasic 인증 사용 X
.formLogin(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
// URL별 접근 권한 설정
.authorizeHttpRequests(authz -> authz
.requestMatchers("/api/v1/signup", "/api/v1/login").permitAll() // 회원가입, 로그인 API는 인증 없이 누구나 접근 가능하도록 허용
.anyRequest().authenticated() // 그 외 모든 요청은 반드시 인증된 사용자만 접근 가능
)
.addFilterBefore(new JwtAuthenticationFilter(jwtProvider, userDetailsService), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration(); // CORS 정책을 설정하기 위한 객체 생성
config.setAllowCredentials(true); // 요청에 쿠키나 인증 헤더 같은 자격 증명(credentials)을 포함하도록 허용
config.setAllowedOrigins(List.of("http://localhost:3000")); // 요청을 허용할 출처(Origin)를 지정. 배포시에는 그 도메인주소로 설정
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")); // 허용할 HTTP 메서드 지정
config.setAllowedHeaders(List.of("*")); // 허용할 요청 헤더 지정. "*"는 모든 헤더 허용
config.setExposedHeaders(List.of("*")); // 클라이언트(브라우저)에서 노출할 응답 헤더를 지정
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); // URL 기반으로 CORS 설정을 관리하는 소스 객체를 생성
source.registerCorsConfiguration("/**", config); // "/**" 패턴, 즉 모든 URL 경로에 대해 위에서 정의한 CORS 정책(config) 적용
return source; // 설정이 완료된 소스 객체를 반환합니다.
}
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
}
🌐 filterChain(HttpSecurity http) : Spring Security의 핵심 보안 설정 담당
🌐 corsConfigurationSource() : CORS(Cross-Origin Resource Sharing) 정책 정의
🌐 passwordEncoder() : 비밀번호를 안전하게 암호화하기 위한 암호화 도구 생성
5. JwtProvider 파일 생성
토큰을 본격적으로 사용하는 파일.
주로 JWT 생성, JWT 검증, 정보 추출의 역할을 한다.
@Slf4j
@Component
public class JwtProvider {
private final Key key;
// application.yml 또는 application.properties에서 시크릿 키를 주입받음
public JwtProvider(@Value("${jwt.secret}") String secretKey) {
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
this.key = Keys.hmacShaKeyFor(keyBytes);
}
/**
* 사용자 정보를 기반으로 Access Token과 Refresh Token을 생성
* @param userId - 토큰을 발급받을 사용자 ID
* @return 생성된 토큰 정보 (TokenDto)
*/
public JwtToken generateToken(String userId) {
long now = (new Date()).getTime();
// Access Token 생성 (유효 기간: 1시간)
Date accessTokenExpiresIn = new Date(now + 3600000); // 1시간
String accessToken = Jwts.builder()
.setSubject(userId)
.setExpiration(accessTokenExpiresIn)
.signWith(key, SignatureAlgorithm.HS256)
.compact();
// Refresh Token 생성 (유효 기간: 7일)
String refreshToken = Jwts.builder()
.setExpiration(new Date(now + 604800000)) // 7일
.signWith(key, SignatureAlgorithm.HS256)
.compact();
return JwtToken.builder()
.grantType("Bearer")
.accessToken(accessToken)
.refreshToken(refreshToken)
.build();
}
/**
* JWT 토큰의 유효성을 검증
* @param token - 검증할 토큰 문자열
* @return 토큰이 유효하면 true, 아니면 false
*/
public boolean validateToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
return true;
} catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
log.info("잘못된 JWT 서명입니다.");
} catch (ExpiredJwtException e) {
log.info("만료된 JWT 토큰입니다.");
} catch (UnsupportedJwtException e) {
log.info("지원되지 않는 JWT 토큰입니다.");
} catch (IllegalArgumentException e) {
log.info("JWT 토큰이 잘못되었습니다.");
}
return false;
}
/**
* 토큰에서 사용자 ID를 추출
* @param accessToken - 사용자 ID를 추출할 토큰
* @return 추출된 사용자 ID
*/
public String getUserId(String accessToken) {
Claims claims = parseClaims(accessToken);
return claims.getSubject();
}
/**
* 토큰을 파싱하여 클레임(정보)을 추출 (만료된 토큰도 정보 추출 가능)
* @param accessToken - 파싱할 토큰
* @return 추출된 클레임
*/
private Claims parseClaims(String accessToken) {
try {
return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody();
} catch (ExpiredJwtException e) {
return e.getClaims();
}
}
}
- 역할: JWT 관련 모든 기능을 제공하는 핵심 도구
- 생성자: 설정 파일의 비밀 키를 가져와 JWT 서명에 사용할 키를 생성
- generateToken 메서드:
- 사용자 ID를 받아 단기 액세스 토큰과 장기 리프레시 토큰을 생성
- 생성된 토큰들을 TokenDto에 담아 반환
- validateToken 메서드:
- 토큰을 입력받아 서명, 유효기간 등을 검증
- 유효하면 true, 아니면 false 반환
- getUserId 메서드:
- 유효한 토큰에서 사용자 ID 정보를 추출
- parseClaims 메서드:
- 토큰의 데이터 부분(Payload)을 꺼내는 내부 기능
6. JwtAuthenticationFilter 구현
클라이언트의 모든 요청을 가로채서, 요청 헤더에 담긴 JWT를 검증하고, 유효하다면 해당 사용자를 인증 상태로 만들어주는 역할
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtProvider;
private final UserDetailsService userDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
// 1. 요청 헤더에서 토큰 추출
String token = resolveToken(request);
// 2. 토큰 유효성 검사
if (token != null && jwtProvider.validateToken(token)) {
// 토큰이 유효할 경우 토큰에서 Authentication 객체를 가져와서 SecurityContext에 저장
String username = jwtProvider.getUserId(token);
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
// 인증 객체 생성
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
// SecurityContext에 인증 정보 저장
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
// Request Header에서 토큰 정보 추출
private String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
}
7-1. User 엔티티 생성
데이터베이스에 저장될 사용자 정보를 담는 User 엔티티 생성
@Entity
@Getter
@NoArgsConstructor
@Table(name = "users")
public class User implements UserDetails {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String username; // 로그인 ID
@Column(nullable = false)
private String password; // 암호화된 비밀번호
@ElementCollection(fetch = FetchType.EAGER)
@Builder.Default
private List<String> roles = new ArrayList<>(); // 사용자의 권한 목록
@Builder
public User(String username, String password, List<String> roles) {
this.username = username;
this.password = password;
this.roles = roles;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
// 'roles' 목록을 Spring Security가 이해할 수 있는 권한(GrantedAuthority) 객체로 변환
return this.roles.stream()
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
}
@Override
public String getPassword() {
return this.password; // 암호화된 비밀번호 반환
}
@Override
public String getUsername() {
return this.username; // 로그인 ID 반환
}
// 아래 4개는 계정 상태에 대한 설정입니다. (지금은 모두 true로 설정)
@Override
public boolean isAccountNonExpired() {
return true; // 계정 만료 여부
}
@Override
public boolean isAccountNonLocked() {
return true; // 계정 잠금 여부
}
@Override
public boolean isCredentialsNonExpired() {
return true; // 비밀번호 만료 여부
}
@Override
public boolean isEnabled() {
return true; // 계정 활성화 여부
}
}
8. UserRepository 생성
위에서 만든 User 엔티티의 데이터베이스 작업을 처리할 UserRepository 생성
public interface UserRepository extends JpaRepository<User, Long> {
// username을 기반으로 사용자를 찾는 메서드
Optional<User> findByUsername(String username);
}
9. UserDetailsService 구현
Spring Security가 사용자 정보를 조회할 때 사용할 서비스인 UserDetailsService의 실제 구현체 생성
이 클래스가 JwtAuthenticationFilter에서 호출됨
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// UserRepository를 통해 DB에서 사용자를 찾아 UserDetails 타입으로 반환합니다.
// 사용자를 찾지 못하면 예외를 발생시킵니다.
return userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("해당하는 유저를 찾을 수 없습니다."));
}
}
10. 로그인, 회원가입 로직 구현하기
10-1. UserService 생성
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final JwtProvider jwtProvider;
private final AuthenticationManagerBuilder authenticationManagerBuilder;
@Transactional
public Long signUp(String username, String password) {
if (userRepository.findByUsername(username).isPresent()) {
throw new IllegalArgumentException("이미 사용 중인 아이디입니다.");
}
String encodedPassword = passwordEncoder.encode(password);
User user = User.builder()
.username(username)
.password(encodedPassword)
.roles(Collections.singletonList("USER")) // 기본 권한
.build();
return userRepository.save(user).getId();
}
@Transactional
public JwtToken login(String username, String password) {
// 1. ID/PW 기반으로 AuthenticationToken 생성
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(username, password);
// 2. 실제 검증 (비밀번호 대조)
// 이 과정에서 CustomUserDetailsService의 loadUserByUsername 메서드가 실행됨
Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
// 3. 인증 정보를 기반으로 JWT 생성
return jwtProvider.generateToken(authentication.getName());
}
}
10-2. UserController 생성
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1")
public class UserController {
private final UserService userService;
@PostMapping("/signup")
public ResponseEntity<String> signUp(@RequestBody UserRequestDto request) {
userService.signUp(request.getUsername(), request.getPassword());
return ResponseEntity.ok("회원가입이 완료되었습니다.");
}
@PostMapping("/login")
public ResponseEntity<JwtToken> login(@RequestBody UserRequestDto request) {
JwtToken token = userService.login(request.getUsername(), request.getPassword());
return ResponseEntity.ok(token);
}
// 요청 시 사용할 DTO
@Data
static class UserRequestDto {
private String username;
private String password;
}
}
이것으로 Spring Security와 JWT를 이용한 로그인 시스템의 모든 구현이 끝났습니다.
전체 흐름
- 최초 요청: 클라이언트가 ID/PW로 /api/v1/login을 호출
- 인증 및 토큰 발급: UserService가 ID/PW를 검증하고, 성공 시 JwtProvider를 통해 Access/Refresh 토큰을 발급하여 클라이언트에게 전달
- 인증이 필요한 요청: 클라이언트는 발급받은 accessToken을 Authorization: Bearer <토큰> 형식으로 HTTP 헤더에 담아 다른 API 요청
- 필터 기반 검증: JwtAuthenticationFilter가 요청을 가로채 헤더의 토큰을 검증함. 토큰이 유효하면, CustomUserDetailsService를 통해 사용자 정보를 찾아 SecurityContext에 저장하여 "인증된 사용자"로 만듬.
- API 로직 수행: 인증이 완료되었으므로 컨트롤러의 로직이 정상적으로 수행
후기 : 항상 간단하게 패스워드 받고 로그인하는 과정만 만들어봐서 신기했다. 토큰이라해서 어렵게 생각했는데, 작은 단위에서 하나하나 만들다보니 유기적으로 연계된게 느껴져서 이해가 더 쉬웠다. JWT를 실제로 해보니 더 깊게 이해하게 된 것 같다.