
iOS 와의 협업으로 인해 Spring Security 를 REST API 로 만들기 위해 공부하는 과정을 기록하고자합니다.
이 단계에서는 필터를 동작시켜 요청(request) 에서 JWT 토큰을 추출하고 디코딩하여 UserPrincipalAuthenticationToken 으로 변환 후 SecurityContextHolder 에 설정합니다. 이를 통해 각 요청에 대한 사용자 정보를 Spring Security에서 활용할 수 있도록 합니다.
공부중이므로 틀린 내용, 의견, 질문 있으시면 댓글 남겨주시면 감사하겠습니다.

UserPrincipal은 Spring Security에서 사용되는 사용자 인증 및 권한 정보를 제공하는 데에 중점을 두고, UserEntity는 애플리케이션에서 사용되는 사용자 데이터를 나타냅니다. 보통은 인증과 권한 검사를 위해 UserPrincipal을 사용하고, 데이터베이스와의 상호 작용 등에서는 UserEntity를 활용합니다.
UserDetails를 구현하므로, Spring Security가 인증 및 권한 검사를 수행할 때 필요한 정보를 제공합니다.
package com.ward.ward_server.security;
import lombok.Builder;
import lombok.Getter;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.Collection;
@Getter
@Builder
public class UserPrincipal implements UserDetails {
private final Long userId;
private final String email;
private final Collection<? extends GrantedAuthority> authorities;
// 사용자에게 부여된 권한 목록을 반환한다.
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorities;
}
// 사용자의 비밀번호를 반환한다.
@Override
public String getPassword() {
return null;
}
// 사용자의 이름(아이디)를 반환한다.
@Override
public String getUsername() {
return email;
}
// 계정이 만료되지 않았는지?
@Override
public boolean isAccountNonExpired() {
return true; // true: 만료되자 않았다.
}
// 계정이 잠겨있지 않은지?
@Override
public boolean isAccountNonLocked() {
return true; // true: 잠겨있지 않다.
}
// 자격 증명이 만료되지 않았는지?
@Override
public boolean isCredentialsNonExpired() {
return true; // 만료되지 않았다.
}
// 활성화되어 있는지?
@Override
public boolean isEnabled() {
return true; // 활성화 되어있다
}
}
getAuthorities() 메서드:
사용자에게 부여된 권한 목록을 반환합니다.
여기에서는 null을 반환하고 있으므로 사용자에게는 권한이 없는 것으로 간주됩니다. 이 메서드를 적절히 구현하여 사용자의 권한을 반환해야 합니다.
getPassword() 및 getUsername() 메서드:
각각 사용자의 비밀번호와 사용자 이름(아이디)을 반환합니다.
현재 구현에서는 두 메서드 모두 null을 반환하고 있으므로, 이후에 이 정보를 설정하도록 구현이 필요합니다.
isAccountNonExpired(), isAccountNonLocked(), isCredentialsNonExpired(), isEnabled() 메서드:
계정이 만료되지 않았는지, 잠겨 있지 않은지, 자격 증명이 만료되지 않았는지, 활성화되어 있는지를 나타내는 불리언 값을 반환합니다.
각각 true를 반환하고 있으므로, 기본적으로 계정은 만료되지 않았고, 잠겨 있지 않으며, 자격 증명이 만료되지 않았으며, 활성화되어 있는 것으로 처리됩니다.
이 클래스는 사용자의 UserPrincipal을 기반으로 한 Spring Security의 인증 토큰을 나타냅니다. 주로 사용자의 권한 정보와 함께 사용되며, 실제 시스템에서는 사용자의 자격 증명을 가져오거나 검증하는 로직을 더 추가하여 사용될 것입니다.
package com.ward.ward_server.security;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import java.util.Collection;
public class UserPrincipalAuthenticationToken extends AbstractAuthenticationToken {
/**
* Creates a token with the supplied array of authorities.
*
* @param authorities the collection of <tt>GrantedAuthority</tt>s for the principal
* represented by this authentication object.
*/
public UserPrincipalAuthenticationToken(Collection<? extends GrantedAuthority> authorities) {
super(authorities);
}
@Override
public Object getCredentials() {
return null;
}
@Override
public Object getPrincipal() {
return null;
}
}
extends AbstractAuthenticationTokenAbstractAuthenticationToken 확장:
UserPrincipal 필드:
생성자:
`getCredentials() 메서드:
`getPrincipal() 메서드:
이 클래스는 JWT 토큰을 디코딩하는 역할을 수행하며, 시크릿 키를 사용하여 토큰이 유효한지 확인합니다. 주로 JWT 토큰을 검증하고 필요한 정보를 추출하기 위해 사용될 것입니다.
package com.ward.ward_server.security;
import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.interfaces.DecodedJWT;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
@Component
@RequiredArgsConstructor // @Bean 주입할거기때문에 @RequiredArgsConstructor 어노테이션 사용
public class JwtDecoder {
private final JwtProperties properties;
public DecodedJWT decode(String token) {
return JWT.require(Algorithm.HMAC256(properties.getSecretKey()))
.build()
.verify(token);
}
}
private final JwtProperties properties; inject이 단계에선 안배우지만 require().withIssuer() / withClaim사용에 대해 공부해보면 좋을듯
@Component 어노테이션:
@RequiredArgsConstructor 어노테이션:
생성자
decode 메서드:
이 클래스는 주로 JWT 토큰에서 사용자 관련 정보 및 권한을 추출하여 UserPrincipal 객체로 변환하는 데 사용될 것입니다.
package com.ward.ward_server.security;
import com.auth0.jwt.interfaces.DecodedJWT;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.stereotype.Component;
import java.util.List;
@Component
public class JwtToPrincipalConverter {
public UserPrincipal convert(DecodedJWT jwt) {
return UserPrincipal.builder()
.userId(Long.valueOf(jwt.getSubject()))
.email(jwt.getClaim("e").asString())
.authorities(extractAuthoritiesFromClaim(jwt))
.build();
}
private List<SimpleGrantedAuthority> extractAuthoritiesFromClaim(DecodedJWT jwt) {
var claim = jwt.getClaim("a");
if (claim.isNull() || claim.isMissing()) return List.of();
return claim.asList(SimpleGrantedAuthority.class);
}
}
extract the token from authorization header from our request
이 필터는 모든 HTTP 요청에서 JWT를 추출하고 디코딩하여 UserPrincipalAuthenticationToken으로 변환한 후, Spring Security의 SecurityContextHolder에 설정합니다. 이를 통해 각 요청에 대한 사용자 정보를 Spring Security에서 활용할 수 있도록 합니다.
이 코드는 Spring Security의 OncePerRequestFilter를 상속하여 JWT를 사용하여 인증을 처리하는 필터입니다. 여러 번 호출되는 것을 방지하는 OncePerRequestFilter를 상속하므로 각 HTTP 요청에 대해 한 번만 실행됩니다.
package com.ward.ward_server.security;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.Optional;
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtDecoder jwtDecoder;
private final JwtToPrincipalConverter jwtToPrincipalConverter;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
extractTokenFromRequest(request)
.map(jwtDecoder::decode)
.map(jwtToPrincipalConverter::convert)
.map(UserPrincipalAuthenticationToken::new)
.ifPresent(authentication -> SecurityContextHolder.getContext().setAuthentication(authentication));
filterChain.doFilter(request, response);
}
// Authorization: Bearer ey74823y58734.y34t897y34.y8934t8934 이런식이라서 substring 7 해서 ey 부분만 가져오기위해서
private Optional<String> extractTokenFromRequest(HttpServletRequest request) {
var token = request.getHeader("Authorization");
if (StringUtils.hasText(token) && token.startsWith("Bearer ")) {
return Optional.of(token.substring(7));
}
return Optional.empty();
}
}
extends OncePerRequestFilterfilterChain.doFilter(request, response); 추가 중요(많이 빼먹는 실수)jwtDecoder,jwtToPrincipalConverterdoFilterInternal 메서드:
extractTokenFromRequest 메서드: HTTP 헤더에서 JWT 토큰을 추출하는 메서드입니다. 주어진 요청의 "Authorization" 헤더에서 "Bearer"로 시작하는 부분을 찾아서 추출합니다.
JwtDecoder와 JwtToPrincipalConverter: 주입된 JwtDecoder는 JWT를 디코딩하는 데 사용되고, JwtToPrincipalConverter는 디코딩된 JWT에서 UserPrincipal 객체로 변환하는 데 사용됩니다.
종합하면, 이 필터는 모든 HTTP 요청에서 JWT를 추출하고 디코딩하여 UserPrincipalAuthenticationToken으로 변환한 후, Spring Security의 SecurityContextHolder에 설정합니다. 이를 통해 각 요청에 대한 사용자 정보를 Spring Security에서 활용할 수 있도록 합니다.
만든 Filter에 대한 내용 추가
package com.ward.ward_server.security;
import lombok.RequiredArgsConstructor;
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.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class WebSecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
@Bean
public SecurityFilterChain applicationSecurity(HttpSecurity http) throws Exception {
http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
http
.cors(AbstractHttpConfigurer::disable)
.csrf(AbstractHttpConfigurer::disable)
.securityMatcher("/**") // map current config to given resource path
.sessionManagement(sessionManagementConfigurer
-> sessionManagementConfigurer.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.formLogin(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(registry -> registry // 요청에 대한 권한 설정 메서드
.requestMatchers("/").permitAll() // / 경로 요청에 대한 권한을 설정. permitAll() 모든 사용자, 인증되지않은 사용자에게 허용
.requestMatchers("/auth/login").permitAll()
.anyRequest().authenticated() // 다른 나머지 모든 요청에 대한 권한 설정, authenticated()는 인증된 사용자에게만 허용, 로그인해야만 접근 가능
);
return http.build();
}
}
private final JwtAuthenticationFilter jwtAuthenticationFilter; 추가http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); 추가http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); 코드는 Spring Security에서 사용되는 필터 체인에 커스텀한 JwtAuthenticationFilter를 추가하는 구문입니다.
여기서 addFilterBefore 메서드는 기존에 등록된 필터들 앞에 새로운 필터를 추가한다는 의미입니다. 즉, JwtAuthenticationFilter가 다른 기본적인 Spring Security 필터들보다 먼저 실행되도록 설정하는 역할을 합니다.
UsernamePasswordAuthenticationFilter.class는 Spring Security에서 제공하는 기본적인 인증 필터 중 하나입니다. 이 필터는 사용자의 아이디와 비밀번호를 이용한 폼 기반 로그인을 처리하는 역할을 합니다. 기본적으로 Spring Security는 사용자가 로그인할 때 UsernamePasswordAuthenticationFilter를 사용하여 아이디와 비밀번호를 확인하고, 성공 시 해당 사용자에게 인증을 부여합니다.
addFilterBefore 메서드를 통해 JwtAuthenticationFilter를 UsernamePasswordAuthenticationFilter 앞에 추가함으로써, HTTP 요청이 들어올 때 먼저 JWT*를 확인하고, 이를 기반으로 사용자를 인증하도록 설정합니다. 따라서 이 구문은 사용자가 JWT를 통한 인증을 시도하기 전에 수행되는 필터 체인에서 먼저 JwtAuthenticationFilter가 실행되도록 하는 역할을 합니다.
package com.ward.ward_server.controller;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequiredArgsConstructor
public class HelloController {
@GetMapping("/")
public String greeting(){
return "Hello, World";
}
@GetMapping("/secured")
public String secured() {
return "If you see this, then you're logged in";
}
}
WebSecurityConfig에 .anyRequest().authenticated()라고 설정해놔서 설정한 endpoint 외에는 로그인 이 필요함. 위와같이 만들어서 테스트합니다. (postman 사용하여)
1. 그냥 실행하고 endpoint 입력하면 403 forbidden 이 뜹니다. 권한 외에는 시큐리티가 잘 동작함을 확인.

2. postman을 /login 통해 토큰 발행하고 다시 /secured 하면 원하는 결과가 나옵니다.

3. postman 에서 Get-Authorization-Type: Bearer Tokeen - Token 입력 후 send

메세지가 뜨면 로그인이 된겁니다.
프로젝트 시작을 위해서만 한거라면 여기까지만하고 코딩해도됩니다.
다음단계에서는 fake login 말고 real login 알아보겠습니다.