[Spring-Security] Jwt back-front 환경에서 jwt 사용하기
in Spring on Spring
Flow
- Filter 체인 : Spring Security는 다양한 Filter들의 체인으로 구성되어 있다. 이 Filter 체인은 Request를 가로챈 후 일련의 절차를 처리한다. UsernamePasswordAuthenticationFilter는 사용자가 제출한 인증 정보를 처리한다.
- UsernamePasswordAuthenticationToken 생성 : UsernamePasswordAuthenticationFilter는 UsernamePasswordAuthenticationToken을 생성하여 AuthenticationManager에게 전달한다. 이 토큰에는 사용자가 제출한 인증 정보가 포함되어 있다.
- AuthenticationManager : AuthenticationManager는 실제로 인증을 수행하는데, 여러 AuthenticationProvider들을 이용한다.
- AuthenticationProvider : 각각의 Provider들은 특정 유형의 인증을 처리한다. 예시로 DaoAuthenticationProvider는 사용자 정보를 데이터베이스에서 가져와 인증을 수행한다.
- PasswordEncoder : 인증과 인가에서 사용될 패스워드의 인코딩 방식을 지정한다.
- UserDetailsService : AuthenticationProvider는 UserDetailsService를 사용하여 사용자 정보를 가져온다. UserDetailsService는 사용자의 아이디를 받아 loadbyUsername을 호출하여 해당 사용자의 UserDetails를 반환한다.
- UserDetails : UserDetails에는 사용자의 아이디, 비밀번호, 권한 등이 포함되어 있다.
- Authentication 객체 생성 : 인증이 성공하면, AuthenticationProvider는 Authentication 객체를 생성하여 AuthenticationManager에게 반환한다. 이 Authentication 객체에는 사용자의 세부 정보와 권한이 포함되어 있다.
- SecurityContextHolder : 현재 실행 중인 스레드에 대한 SecurityContext를 제공한다.
- SecurityContext : 현재 사용자의 Authentication이 저장되어 있다. 애플리케이션은 SecurityContextHolder를 통해 현재 사용자의 권한을 확인하고, 인가 결정을 한다.
로그인 및 초기 설정
회원가입은 일반 로직과 같다.
의존성
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'
//...
}
yml
jwt:
expiration_time: 86400000 #1일
secret: VlwEyVBsYt9V7zq57TejMnVUyzblYcfPQye08f7MGVA9XkHa
JwtUtil
/**
* [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();
}
}
}
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;
}
Bean 설정
@Configuration
public class PasswordEncoderConfig {
//PasswordEncoder Bean
@Bean
public BCryptPasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
}
LoginRequestDto
@Data
@AllArgsConstructor
@NoArgsConstructor
@Schema(title = "AUTH_REQ_01 : 로그인 요청 DTO")
public class LoginRequestDto {
@NotNull(message = "이메일 입력은 필수입니다.")
@Email
private String email;
@NotNull(message = "패스워드 입력은 필수입니다.")
private String password;
}
CustomUserInfoDto
@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;
}
인가
loadByUserName : 아래에서 작성될 JwtAuthFilter에서 JWT의 유효성을 검증한 이후, JWT에서 추출한 유저 식별자(userId)와 일치하는 User가 데이터베이스에 존재하는지의 여부를 판단하고, 존재하면 Spring Security에서 내부적으로 사용되는 Auth 객체(UserPasswordAuthenticationToken)를 만들 때 필요한 UserDetails 객체로 반환하는 역할을 한다.
본 예제는 UserDetails를 확장하여 CustomUserDetails를 사용한다.
커스텀 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);
}
}
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으로 해당 유저가 데이터베이스에 존재하는지 판단한다.
- 해당 과정이 모두 성공한다면 (userDetails를 정상적으로 받아왔다면 성공이다), UserPasswordAuthenticationToken(스프링 시큐리티 내부에서 인가에 사용되는 친구이다)을 생성하여 현재 요청의 Context에 추가한다.
- Context에 이것이 추가된다는 것은 해당 요청이 필터를 거쳐 인가에 성공하여 승인된 Request라는 의미이다.
SecurityContext
@EnableWebSecurity : Spring Security 컨텍스트 설정임을 명시한다. @EnableGlobalMethodSecurity: Annotation을 통해서 Controller의 API들의 보안 수준을 설정할 수 있도록 활성화한다. Spring 2점대 버전에서는 WebSecurityConfigurerAdapter를 상속받아 구현하는 형태로 많이 사용했었는데, Spring 3에 들어서 해당 방식보다 SecurityFilterChain을 Bean으로 등록하는 방식을 권장하고, Adapter 방식은 Deprecated되었다. 따라서 SecurityFilterChain을 반환하는 filterChain 메서드를 Bean으로 등록하고, 내부에서 Security Chain 설정을 진행한다.
@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();
}
}
- 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를 정의한다.
CustomAuthenticationEntryPoint
@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);
}
}
CustomAccessDeniedHandler
@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);
}
}
메서드 단위 권한 제어
@PostMapping("")
@PreAuthorize("hasRole('ROLE_COMMON')")
public BasicResponseDto exampleAPI(
) {
//...
}
정리
- 사용자가 Request Header에 JWT를 포함하여 API 요청을 수행한다.
- JwtAuthFilter가 해당 요청을 가로채 유효성 검증을 수행한다.
- JWT가 포함되어 있는지 및 서버의 Secret을 사용하여 JWT의 유효성 검증
- JWT의 Claim을 추출하여 UserDetailsService의 loadByUserName을 호출 -> DB에 일치하는 User가 존재하면 UserDetails 생성
- UserDetails을 통해 UsernamePasswordAuthenticationToken을 생성한 후, 해당 토큰을 스프링 시큐리티의 Authentication 컨텍스트에 저장한 후 다음 필터의 처리를 수행하게 한다.
- 만약 해당 과정이 실패(인가 실패) 시 컨텍스트에 인증 정보를 저장하지 않고 다음 필터의 처리로 넘긴다.
- 이제 SecurityConfig에 작성된 엔드포인트별 권한 규칙이나, @PreAuthorize와 같은 메서드별 권한 접근 제어 시, 해당 인증 정보를 기반으로 인가 처리가 승인나거나, 실패하게 된다.