[Spring Boot] Spring Security : JWT Auth (SpringBoot 3 버전)
작년 말, Spring 2점대 버전의 지원이 공식 중단되면서, 이제 웬만하면 Spring 3 버전대를 사용할 것을 Spring 진영에서 권장하고 있다.
그 중 Spring Security의 경우 변화한 내용이 조금 있는 편이라 이 참에 Spring Security를 이용한 JWT 인증과 인가를 해당 버전대에 맞추고, 정리하여 다시 작성해보려고 한다.
아래는 Spring 2점대 버전에서의 JWT Security를 설정했던 포스팅들인데, 3점대 버전에서도 근본적인 과정들이 바뀐 것은 아니다. 만약 2점대 버전에서의 설정이 궁금하다면 아래 포스팅들의 내용을 참고하자.
회원 가입 : 해당 부분은 Security에 의존하지 않아, 회원 가입 로직이 필요하다면 참고하면 될 것 같다.
https://sjh9708.tistory.com/83
로그인 인증(Authentication)
https://sjh9708.tistory.com/84
JWT 인가(Authorization)
https://sjh9708.tistory.com/85
Spring Security
위 그림은 Spring Security측에서 기본적으로 제공하는 인증 처리에 사용되는 모듈들이다.
Spring은 디폴트로 사용할 수 있는 Username과 Password 기반의 로그인 시스템이 내장되어 있다. 하지만 프로젝트에 맞춘 JWT Auth 등 특정 목적을 위해 확장하려면, 해당 모듈들의 일부를 Extend하여 커스텀 필터로 사용하는 방법을 사용하고 있다.
아래의 개념들은 모두 해당 포스팅에서 인증 및 인가를 구현할 때 사용되는 개념들이다.
A. Filter 체인: Spring Security는 다양한 Filter들의 체인으로 구성되어 있다. 이 Filter 체인은 Request를 가로챈 후 일련의 절차를 처리한다. UsernamePasswordAuthenticationFilter는 사용자가 제출한 인증 정보를 처리한다.
B. UsernamePasswordAuthenticationToken 생성: UsernamePasswordAuthenticationFilter는 UsernamePasswordAuthenticationToken을 생성하여 AuthenticationManager에게 전달한다. 이 토큰에는 사용자가 제출한 인증 정보가 포함되어 있다.
C. AuthenticationManager: AuthenticationManager는 실제로 인증을 수행하는데, 여러 AuthenticationProvider들을 이용한다.
D. AuthenticationProvider : 각각의 Provider들은 특정 유형의 인증을 처리한다. 예시로 DaoAuthenticationProvider는 사용자 정보를 데이터베이스에서 가져와 인증을 수행한다.
E. PasswordEncoder : 인증과 인가에서 사용될 패스워드의 인코딩 방식을 지정한다.
F. UserDetailsService : AuthenticationProvider는 UserDetailsService를 사용하여 사용자 정보를 가져온다. UserDetailsService는 사용자의 아이디를 받아 loadbyUsername을 호출하여 해당 사용자의 UserDetails를 반환한다.
G. UserDetails : UserDetails에는 사용자의 아이디, 비밀번호, 권한 등이 포함되어 있다.
H. Authentication 객체 생성: 인증이 성공하면, AuthenticationProvider는 Authentication 객체를 생성하여 AuthenticationManager에게 반환한다. 이 Authentication 객체에는 사용자의 세부 정보와 권한이 포함되어 있다.
I. SecurityContextHolder: 현재 실행 중인 스레드에 대한 SecurityContext를 제공한다.
J. SecurityContext: 현재 사용자의 Authentication이 저장되어 있다. 애플리케이션은 SecurityContextHolder를 통해 현재 사용자의 권한을 확인하고, 인가 결정을 한다.
프로젝트 의존성
▶ build.gradle
plugins {
id 'java'
id 'org.springframework.boot' version '3.2.1'
id 'io.spring.dependency-management' version '1.1.4'
}
group = 'com.example'
version = '0.0.1-SNAPSHOT'
java {
sourceCompatibility = '17'
}
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-security'
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'
implementation 'org.springframework.boot:spring-boot-starter-validation'
runtimeOnly 'org.mariadb.jdbc:mariadb-java-client:2.7.4'
compileOnly 'org.projectlombok:lombok'
//Jwt
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'
implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'
//ModelMapper
implementation group: 'org.modelmapper', name: 'modelmapper', version: '2.4.2'
//...
}
Jwt 사용을 위해 jsonwebtoken 관련 라이브러리와, Spring Security 관련 모듈들을 의존성으로 추가해두자.
▶ application.yml
jwt:
expiration_time: 86400000 #1일
secret: VlwEyVBsYt9V7zq57TejMnVUyzblYcfPQye08f7MGVA9XkHa
JWT의 만료기간과 서명키를 Base64로 암호화하여 설정파일에 작성해두었다.
JWT 처리 유틸리티 클래스 작성
/**
* [JWT 관련 메서드를 제공하는 클래스]
*/
@Slf4j
@Component
public class JwtUtil {
private final Key key;
private final long accessTokenExpTime;
public JwtUtil(
@Value("${jwt.secret}") String secretKey,
@Value("${jwt.expiration_time}") long accessTokenExpTime
) {
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
this.key = Keys.hmacShaKeyFor(keyBytes);
this.accessTokenExpTime = accessTokenExpTime;
}
/**
* Access Token 생성
* @param member
* @return Access Token String
*/
public String createAccessToken(CustomUserInfoDto member) {
return createToken(member, accessTokenExpTime);
}
/**
* JWT 생성
* @param member
* @param expireTime
* @return JWT String
*/
private String createToken(CustomUserInfoDto member, long expireTime) {
Claims claims = Jwts.claims();
claims.put("memberId", member.getMemberId());
claims.put("email", member.getEmail());
claims.put("role", member.getRole());
ZonedDateTime now = ZonedDateTime.now();
ZonedDateTime tokenValidity = now.plusSeconds(expireTime);
return Jwts.builder()
.setClaims(claims)
.setIssuedAt(Date.from(now.toInstant()))
.setExpiration(Date.from(tokenValidity.toInstant()))
.signWith(key, SignatureAlgorithm.HS256)
.compact();
}
/**
* Token에서 User ID 추출
* @param token
* @return User ID
*/
public Long getUserId(String token) {
return parseClaims(token).get("memberId", Long.class);
}
/**
* JWT 검증
* @param token
* @return IsValidate
*/
public boolean validateToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
return true;
} catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
log.info("Invalid JWT Token", e);
} catch (ExpiredJwtException e) {
log.info("Expired JWT Token", e);
} catch (UnsupportedJwtException e) {
log.info("Unsupported JWT Token", e);
} catch (IllegalArgumentException e) {
log.info("JWT claims string is empty.", e);
}
return false;
}
/**
* JWT Claims 추출
* @param accessToken
* @return JWT Claims
*/
public Claims parseClaims(String accessToken) {
try {
return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody();
} catch (ExpiredJwtException e) {
return e.getClaims();
}
}
}
JWT를 다루기 위한 기능들을 작성한 클래스를 정의해주자.
JWT를 생성하고, 유효성을 검증하고, 안의 Claim들을 추출할 수 있는 메서드들을 작성하였다.
로그인 구현 (Authentication)
▶ Controller
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/auth")
public class AuthApiController {
private final AuthService authService;
@PostMapping("login")
public ResponseEntity<String> getMemberProfile(
@Valid @RequestBody LoginRequestDto request
) {
String token = this.authService.login(request);
return ResponseEntity.status(HttpStatus.OK).body(token);
}
}
▶ Service
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class AuthServiceImpl implements AuthService{
private final JwtUtil jwtUtil;
private final MemberRepository memberRepository;
private final PasswordEncoder encoder;
private final ModelMapper modelMapper;
@Override
@Transactional
public String login(LoginRequestDto dto) {
String email = dto.getEmail();
String password = dto.getPassword();
Member member = memberRepository.findMemberByEmail(email);
if(member == null) {
throw new UsernameNotFoundException("이메일이 존재하지 않습니다.");
}
// 암호화된 password를 디코딩한 값과 입력한 패스워드 값이 다르면 null 반환
if(!encoder.matches(password, member.getPassword())) {
throw new BadCredentialsException("비밀번호가 일치하지 않습니다.");
}
CustomUserInfoDto info = modelMapper.map(member, CustomUserInfoDto.class);
String accessToken = jwtUtil.createAccessToken(info);
return accessToken;
}
}
▶ Repository
public interface MemberRepository extends JpaRepository<Member, Long> {
// 쿼리 메서드
Member findMemberByEmail(String email);
}
▶ Entity
@Entity
@Table(name = "MEMBER")
@Getter
@Setter
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "MEMBER_ID")
private Long memberId;
@Column(name = "email", nullable = false, unique = true)
private String email;
@Column(name = "NAME", nullable = false)
private String name;
@Column(name = "PASSWORD", nullable = false)
private String password;
@Enumerated(EnumType.STRING)
@Column(name = "ROLE", nullable = false)
private RoleType role;
@OneToMany(mappedBy = "member", cascade = CascadeType.ALL)
private List<Category> categories;
}
- role : 해당 유저의 권한, 예를 들면 대학교 강의 시스템이라면 학생/교수자로 나눠서 계정 유형을 생성하기 위한 속성이다. 인가 진행 시 해당 권한을 기반으로 접근할 수 있는 자원을 제한할 수 있도록 설정할 예정이다.
- categories는 해당 인증 기능에서 필요한 요구사항이었기 때문에 신경쓰지 않아도 된다.
▶ Encoder
@Configuration
public class PasswordEncoderConfig {
//PasswordEncoder Bean
@Bean
public BCryptPasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
}
간단하게만 설명하고 넘어가면 로그인 시, Email과 패스워드를 사용자로부터 받아, 데이터베이스의 Email과 Password와 비교한다.
Password는 Encoder에 의해 Bcrypt로 암호화된 형태로 데이터베이스에 저장되어 있도록 하였기 때문에, Encoder를 통해 비교하는 로직을 사용한다.
비교하여 통과한다면 JwtUtil의 JWT 생성 메서드를 호출하여 해당 토큰을 응답으로 반환하는 Service 계층을 작성하였다. 이를 컨트롤러에서 사용하여 로그인 성공 시 토큰을 반환하고, 실패 시 Exception을 반환한다.
로그인 요청 DTO와, 로직 내부에서 유저 정보를 저장해 둘 DTO 코드
로그인 요청 DTO
▶ LoginRequestDto.java
@Data
@AllArgsConstructor
@NoArgsConstructor
@Schema(title = "AUTH_REQ_01 : 로그인 요청 DTO")
public class LoginRequestDto {
@NotNull(message = "이메일 입력은 필수입니다.")
@Email
private String email;
@NotNull(message = "패스워드 입력은 필수입니다.")
private String password;
}
로직 내부에서 인증 유저 정보를 저장해 둘 DTO
▶ CustomUserInfoDto.java
@Data
@AllArgsConstructor
@NoArgsConstructor
@Getter
@Setter
public class CustomUserInfoDto extends MemberDto{
private Long memberId;
private String email;
private String name;
private String password;
private RoleType role;
}
인가 (Authrozation)
그래서 우리가 JWT 인가를 수행하기 위해 이번 포스팅에서 구현할 내용은 위의 내용과 같다.
앱이 사용자의 Request를 수행하기 이전에 가로채서 Header의 JWT에 대한 검증을 수행하는 JwtAuthFilter를 만들 것이고, Spring이 기본적으로 제공하는 UserDetails에서 속성들을 추가시키기 위해서 Custom UserDetails(+UserDetailsService)를 사용할 것이다.
마지막으로 해당 필터를 SecurityContext에 등록하여 요청에 대한 JWT 필터가 수행되도록 해 보자.
커스텀 UserDetails, UserDetailsService
@Getter
@RequiredArgsConstructor
public class CustomUserDetails implements UserDetails {
private final CustomUserInfoDto member;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
List<String> roles = new ArrayList<>();
roles.add("ROLE_" + member.getRole().toString());
return roles.stream()
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
}
@Override
public String getPassword() {
return member.getPassword();
}
@Override
public String getUsername() {
return member.getMemberId().toString();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
private final MemberRepository memberRepository;
private final ModelMapper mapper;
@Override
public UserDetails loadUserByUsername(String id) throws UsernameNotFoundException {
Member member = memberRepository.findById(Long.parseLong(id))
.orElseThrow(() -> new UsernameNotFoundException("해당하는 유저가 없습니다."));
CustomUserInfoDto dto = mapper.map(member, CustomUserInfoDto.class);
return new CustomUserDetails(dto);
}
}
- loadByUserName : 아래에서 작성될 JwtAuthFilter에서 JWT의 유효성을 검증한 이후, JWT에서 추출한 유저 식별자(userId)와 일치하는 User가 데이터베이스에 존재하는지의 여부를 판단하고, 존재하면 Spring Security에서 내부적으로 사용되는 Auth 객체(UserPasswordAuthenticationToken)를 만들 때 필요한 UserDetails 객체로 반환하는 역할을 한다.
- 우리는 UserDetails를 확장하여 CustomUserDetails를 사용했었다.
- 우리는 UserDetails를 확장하여 CustomUserDetails를 사용했었다.
JwtAuthFilter
@RequiredArgsConstructor
public class JwtAuthFilter extends OncePerRequestFilter { // OncePerRequestFilter -> 한 번 실행 보장
private final CustomUserDetailsService customUserDetailsService;
private final JwtUtil jwtUtil;
@Override
/**
* JWT 토큰 검증 필터 수행
*/
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String authorizationHeader = request.getHeader("Authorization");
//JWT가 헤더에 있는 경우
if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {
String token = authorizationHeader.substring(7);
//JWT 유효성 검증
if (jwtUtil.validateToken(token)) {
Long userId = jwtUtil.getUserId(token);
//유저와 토큰 일치 시 userDetails 생성
UserDetails userDetails = customUserDetailsService.loadUserByUsername(userId.toString());
if (userDetails != null) {
//UserDetsils, Password, Role -> 접근권한 인증 Token 생성
UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken =
new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
//현재 Request의 Security Context에 접근권한 설정
SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
}
}
}
filterChain.doFilter(request, response); // 다음 필터로 넘기기
}
}
- 해당 필터에서는 위에서 설명했듯 JWT가 유효한 토큰인지를 판단하고, 유효하다면 UserDetailService의 loadByUserName으로 해당 유저가 데이터베이스에 존재하는지 판단한다.
- 유효성 판단이라는 것은 서명 확인(내가 준 토큰이 맞는지), 만료된 토큰인지(Expire Time 판단) 등을 판단하는 것이다.
- 해당 과정이 모두 성공한다면 (userDetails를 정상적으로 받아왔다면 성공이다), UserPasswordAuthenticationToken(스프링 시큐리티 내부에서 인가에 사용되는 친구이다)을 생성하여 현재 요청의 Context에 추가한다.
- Context에 이것이 추가된다는 것은 해당 요청이 필터를 거쳐 인가에 성공하여 승인된 Request라는 의미이다.
SecurityContext
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true)
@AllArgsConstructor
public class SecurityConfig {
private final CustomUserDetailsService customUserDetailsService;
private final JwtUtil jwtUtil;
private final CustomAccessDeniedHandler accessDeniedHandler;
private final CustomAuthenticationEntryPoint authenticationEntryPoint;
private static final String[] AUTH_WHITELIST = {
"/api/v1/member/**", "/swagger-ui/**", "/api-docs", "/swagger-ui-custom.html",
"/v3/api-docs/**", "/api-docs/**", "/swagger-ui.html", "/api/v1/auth/**"
};
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
//CSRF, CORS
http.csrf((csrf) -> csrf.disable());
http.cors(Customizer.withDefaults());
//세션 관리 상태 없음으로 구성, Spring Security가 세션 생성 or 사용 X
http.sessionManagement(sessionManagement -> sessionManagement.sessionCreationPolicy(
SessionCreationPolicy.STATELESS));
//FormLogin, BasicHttp 비활성화
http.formLogin((form) -> form.disable());
http.httpBasic(AbstractHttpConfigurer::disable);
//JwtAuthFilter를 UsernamePasswordAuthenticationFilter 앞에 추가
http.addFilterBefore(new JwtAuthFilter(customUserDetailsService, jwtUtil), UsernamePasswordAuthenticationFilter.class);
http.exceptionHandling((exceptionHandling) -> exceptionHandling
.authenticationEntryPoint(authenticationEntryPoint)
.accessDeniedHandler(accessDeniedHandler)
);
// 권한 규칙 작성
http.authorizeHttpRequests(authorize -> authorize
.requestMatchers(AUTH_WHITELIST).permitAll()
//@PreAuthrization을 사용할 것이기 때문에 모든 경로에 대한 인증처리는 Pass
.anyRequest().permitAll()
// .anyRequest().authenticated()
);
return http.build();
}
}
@EnableWebSecurity : Spring Security 컨텍스트 설정임을 명시한다.
@EnableGlobalMethodSecurity: Annotation을 통해서 Controller의 API들의 보안 수준을 설정할 수 있도록 활성화한다.
Spring 2점대 버전에서는 WebSecurityConfigurerAdapter를 상속받아 구현하는 형태로 많이 사용했었는데, Spring 3에 들어서 해당 방식보다 SecurityFilterChain을 Bean으로 등록하는 방식을 권장하고, Adapter 방식은 Deprecated되었다.
따라서 SecurityFilterChain을 반환하는 filterChain 메서드를 Bean으로 등록하고, 내부에서 Security Chain 설정을 진행한다.
설정한 내용들은 아래와 같다.
- CSRF 보호 비활성화 : CSRF 토큰을 사용하지 않을 것이므로 확인하지 않도록 설정
- CORS 설정을 적용 : 다른 도메인의 웹 페이지에서 리소스에 접근할 수 있도록 허용
- 폼 로그인과 HTTP 기본 인증을 비활성화 : Spring 웹 페이지에서 제공되는 로그인 폼을 통해 사용자를 인증하는 메커니즘과 HTTP 기반 기본 인증을 비활성화한다.
- JwtAuthFilter를 UsernamePasswordAuthenticationFilter 앞에 추가하여, JWT 필터를 거치도록 설정한다. 만약 JwtAuthFilter을 통과하여 Authentication을 획득하였다면 인증 필요(Authenticated)한 자원의 사용이 가능해질 것이다.
- 권한에 따른 접근 가능한 자원(행위)의 규칙 작성 : 제한 방식은 아래의 두 가지로 나뉜다. 우리는 메서드 단위 보안 수준을 사용하였다.
- 메서드 단위로 보안 수준을 설정
- anyRequest()에 대해 permitAll() 해주었는데 기본적으로는 모두 허용해 줄 것이다.(엔드포인트별 보안 수준을 설정하지 않을 것이기 때문)
- EnableGlobalMethodSecurity를 설정해둔 이유는 Annotation으로 메서드 단위로 접근 제한을 하기 위해서이다.
- 엔드포인트별 보안 수준 설정 : 만약 Annotation을 통해 접근 제한을 하지 않을 것이라면 anyRequest() 부분에서 접근 승인할 엔드포인트들을 작성해 주어야 한다.
- 메서드 단위로 보안 수준을 설정
- 인증과 인가 실패 시 Exception Handler를 추가해주었다. Security 단에서 권한 관련 401이나 403 에러 등을 처리해 줄 핸들러를 함께 등록해주었다.
- authenticationEntryPoint는 인증되지 않은 사용자에 대해 처리하는 Handler를 정의한다.
- accessDeniedHandler는 인증되었지만, 특정 리소스에 대한 권한이 없을 경우(인가) 호출되는 Handler를 정의한다.
▶ 사용자 정의 핸들러 코드
@Slf4j(topic = "UNAUTHORIZATION_EXCEPTION_HANDLER")
@AllArgsConstructor
@Component
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
private final ObjectMapper objectMapper;
@Override
public void commence(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException) throws IOException, ServletException {
log.error("Not Authenticated Request", authException);
ErrorResponseDto errorResponseDto = new ErrorResponseDto(HttpStatus.UNAUTHORIZED.value(), authException.getMessage(), LocalDateTime.now());
String responseBody = objectMapper.writeValueAsString(errorResponseDto);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.setCharacterEncoding("UTF-8");
response.getWriter().write(responseBody);
}
}
@Slf4j(topic = "FORBIDDEN_EXCEPTION_HANDLER")
@AllArgsConstructor
@Component
public class CustomAccessDeniedHandler implements AccessDeniedHandler {
private final ObjectMapper objectMapper;
@Override
public void handle(HttpServletRequest request,
HttpServletResponse response,
AccessDeniedException accessDeniedException) throws IOException, ServletException {
log.error("No Authorities", accessDeniedException);
ErrorResponseDto errorResponseDto = new ErrorResponseDto(HttpStatus.FORBIDDEN.value(), accessDeniedException.getMessage(), LocalDateTime.now());
String responseBody = objectMapper.writeValueAsString(errorResponseDto);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setStatus(HttpStatus.FORBIDDEN.value());
response.setCharacterEncoding("UTF-8");
response.getWriter().write(responseBody);
}
}
▶ 2점대 버전 Spring Security Config과 비교하기
이전 방식과 비교해보면 차이점이 꽤 있는데, 앞에서 언급한 WebSecurityConfigurerAdater를 사용하지 않는 것과 이에 따른 메서드 오버라이딩을 사용하지 않고 Bean을 직접 등록하여 사용한다는 점, antMathcer대신 requestMatcher 사용 하는 것 등이 눈에 보인다.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
@EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private final JwtTokenProvider jwtTokenProvider;
private final CustomUserDetailsService customUserDetailsService;
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth
.userDetailsService(customUserDetailsService)
.passwordEncoder(passwordEncoder()); // Set the BCryptPasswordEncoder for password encoding
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/swagger-resources/**").permitAll()
.antMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
.anyRequest().permitAll();
http
.headers()
.frameOptions()
.sameOrigin();
http
.httpBasic().disable()
.csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.cors()
.and()
.formLogin().disable()
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class);
}
메서드 단위 권한 제어
@PostMapping("")
@PreAuthorize("hasRole('ROLE_COMMON')")
public BasicResponseDto exampleAPI(
) {
//...
}
위에서 EnableGlobalMethodSecurity 설정을 해주었기 때문에 접근 권한의 제어를 메서드 단위로 수행할 수 있다.
PreAuthorize 어노테이션을 통해서 Security 보안 수준을 통과해야 해당 API를 사용할 수 있다.
위에서 CustomUserDetails에서 Role을 등록해 둔 것이 기억날 것이다. hasRole은 해당 권한을 가진 유저인지를 확인한다는 것이다. 이 경우, JWT 인증 + Role이 ROLE_COMMON이어야 해당 API를 사용할 수 있게 된다.
Authorization(인가) 정리
1. 사용자가 Request Header에 JWT를 포함하여 API 요청을 수행한다.
2. JwtAuthFilter가 해당 요청을 가로채 유효성 검증을 수행한다.
- JWT가 포함되어 있는지 및 서버의 Secret을 사용하여 JWT의 유효성 검증
- JWT의 Claim을 추출하여 UserDetailsService의 loadByUserName을 호출 -> DB에 일치하는 User가 존재하면 UserDetails 생성
- UserDetails을 통해 UsernamePasswordAuthenticationToken을 생성한 후, 해당 토큰을 스프링 시큐리티의 Authentication 컨텍스트에 저장한 후 다음 필터의 처리를 수행하게 한다.
- 만약 해당 과정이 실패(인가 실패) 시 컨텍스트에 인증 정보를 저장하지 않고 다음 필터의 처리로 넘긴다.
3. 이제 SecurityConfig에 작성된 엔드포인트별 권한 규칙이나, @PreAuthorize와 같은 메서드별 권한 접근 제어 시, 해당 인증 정보를 기반으로 인가 처리가 승인나거나, 실패하게 된다.
'Backend > Spring' 카테고리의 다른 글
[Spring Boot/JPA] QueryDSL 문법(1) : 기본 검색 (선택, 조건, 정렬, 집계, 그룹화) (1) | 2024.01.25 |
---|---|
[Spring Boot/JPA] QueryDSL 설정과 Repository에서의 사용 (2) | 2024.01.23 |
[Spring Boot] Swagger API Docs 작성하기 (SpringDoc, SpringBoot 3 버전) (0) | 2024.01.15 |
[Spring Boot] 사용자 정의 예외처리 : Exception Handler (0) | 2024.01.15 |
[Spring Boot/JPA] Spring data JPA : JpaRepository 사용 (1) | 2024.01.15 |