스프링 시큐리티 구현
토큰값(JWT) 을 활용하여 스프링 시큐리티를 적용하기 위한 컴포넌트에 대해 알아보겠습니다.
JWT 의존성 추가(Maven -> pom.xml)
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
- 스프링 시큐리티는 기본적으로 UsernamePasswordAuthenticationFilter를 통해 인증을 수행하도록 구성되어있다.
- 해당 필터는 인증이 실패하면 로그인 폼이 포함된 화면을 전달한다.
UserDetails와 UserDetailsService 구현
User Entity 생성
@Getter
@Setter
@Entity
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Table
public class User implements UserDetails {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String uid;
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
@Column(nullable = false)
private String password;
@Column(nullable = false)
private String name;
@ElementCollection(fetch = FetchType.EAGER)
@Builder.Default
private List<String> roles = new ArrayList<>();
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return this.roles.stream().
map(SimpleGrantedAuthority::new).collect(Collectors.toList());
}
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
@Override
public String getUsername() {
return this.uid;
}
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
@Override
public boolean isAccountNonExpired() {
return true;
}
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
@Override
public boolean isAccountNonLocked() {
return true;
}
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
@Override
public boolean isEnabled() {
return true;
}
}
- User 엔티티는 UserDetails 인터페이스를 구현하고 있다.
- UserDetails는 UserDetailsService를 통해 입력된 로그인 정보를 가지고 데이터베이스에서 사용자 정보를 가져오는 역할을 수행한다.
UserDetails 인터페이스
public interface UserDetails extends Serializable {
Collection<? extends GrantedAuthority> getAuthorities();
String getPassword();
String getUsername();
boolean isAccountNonExpired();
boolean isAccountNonLocked();
boolean isCredentialsNonExpired();
boolean isEnabled();
}
- getAuthorities( ) : 계정이 가지고 있는 권한 목록을 리턴
- getPassword( ) : 계정의 비밀번호 리턴
- getUsername( ) : 계정의 이름을 리턴, 일반적으로 아이디를 리턴
- isAccountNonExpired( ) : 계정 만료 여부 리턴, true는 만료되지 않았다는 의미이다.
- isAccountNonLocked( ) : 계정 잠김 여부 리턴, true는 잠기지 않았다는 의미이다.
- isCredentialNonExpired( ) : 비밀번호 만료 여부 리턴, true는 만료되지 않았다는 의미이다.
- isEnabled( ) : 계정 활성화 여부 리턴, true는 활성화 상태를 의미한다.
UserRepository 구현
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
User getByUid(String uid);
}
- JpaRepository를 상속받고 User 엔티티에 대해 설정한다.
- 현재 ID 값은 인덱스 값이기 때문에 id 값을 토큰 생성 정보로 사용하기 위해 getByUid( ) 메서드를 생성한다.
UserDetailsServiceImpl 구현
@Service
@RequiredArgsConstructor
public class UserDetailsServiceImpl implements UserDetailsService {
private final Logger LOGGER = LoggerFactory.getLogger(UserDetailsServiceImpl.class);
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
LOGGER.info("[loadUserByUsername] loadUserByUsername 수행. username : {}"
, username);
return userRepository.getByUid(username);
}
}
- UserDetails는 스프링 시큐리티에서 제공하는 개념이다.
- UserDetails의 username은 각 사용자를 구분할 수 있는 ID를 의미한다.
- username을 가지고 UserDetails 객체를 리턴하게끔 정의되어 있는데, UserDetails의 구현체로 User 엔티티를 생성했기에 User 객체를 리턴하게끔 구현하였다.
JwtTokenProvider 구현
- JWT 토큰을 생성하는 데 필요한 정보를 UserDetails에서 가져올 수 있다.
- JWT 토큰을 생성하는 JwtTokenProvider를 생성한다.
JwtTokenProvider 클래스
@Component
@RequiredArgsConstructor
public class JwtTokenProvider {
private final Logger LOGGER = LoggerFactory.getLogger(JwtTokenProvider.class);
private final UserDetailsService userDetailsService;
@Value("${springboot.jwt.secret}")
private String secretKey = "secretKey";
private final long tokenValidMillisecond = 1000L * 60 * 60;
//... 생략
}
- 토큰을 생성하기 위해서는 secretKey가 필요하므로 secretKey 값을 정의한다.
- @Value의 값은 application.properites 파일에서 정의할 수 있다.
springboot.jwt.secret=flature!@#
- application.properties 파일에서 값을 가져올 수 없는 경우 기본값인 'secretkey'를 가져온다.
init( ) 메서드
@PostConstruct
protected void init() {
LOGGER.info("[init] JwtTokenProvider 내 secretKey 초기화 시작");
secretKey = Base64.getEncoder().encodeToString(
secretKey.getBytes(StandardCharsets.UTF_8));
LOGGER.info("[init] JwtTokenProvider 내 secretKey 초기화 완료");
}
- @PostConstruct 어노테이션은 해당 객체가 빈 객체로 주입된 이후 수행하는 메서드를 가리킨다.
- JwtTokenProvider 클래스에는 @Componet 어노테이션이 지정되있기에, 애플리케이션이 가동되면 빈으로 자동 주입된다.
- @PostConstruct가 지정되어있는 메서드인 init( ) 메서드는 자동으로 실행된다.
- secretkey를 Base64 형식으로 인코딩한다.
// 인코딩 전 원본 문자열 flature!@# // Base64 인코딩 결과 ZmxhdHVyZSFAIw==
createToken( ) 메서드
public String createToken(String userUid, List<String> roles) {
LOGGER.info("[createToken] JwtTokenProvider 토큰 생성 시작");
Claims claims = Jwts.claims().setSubject(userUid);
claims.put("roles", roles);
Date now = new Date();
String token = Jwts.builder()
.setClaims(claims)
.setIssuedAt(now)
.setExpiration(new Date(now.getTime() + tokenValidMillisecond))
.signWith(SignatureAlgorithm.HS256, secretKey)
.compact();
LOGGER.info("[createToken] JwtTokenProvider 토큰 생성 완료");
return token;
}
- JWT 토큰의 내용에 값을 넣기 위해 Claims 객체를 생성한다.
- setSubject( ) 메서드를 통해 sub 속성에 값을 추가하려면 User의 uid 값을 사용한다.
- 해당 토큰을 사용하는 사용자의 권한을 확인할 수 있는 role의 값을 별개로 추가한다.
- Jwts.builder( ) 를 사용하여 토큰을 생성한다.
getAuthentication( ) 메서드
public Authentication getAuthentication(String token) {
LOGGER.info("[getAuthentication] 토큰 인증 정보 조회 시작");
UserDetails userDetails =
userDetailsService.loadUserByUsername(this.getUsername(token));
LOGGER.info("[getAuthentication] 토큰 인증 정보 조회 완료," +
" UserDetails UserName : {}", userDetails.getUsername());
return new UsernamePasswordAuthenticationToken(
userDetails, "", userDetails.getAuthorities());
}
- 필터에서 인증이 성공했을 시에 SecurityContextHolder에 저장할 Authentication을 생성하는 역할을 한다.
- Authentication을 구현하는 편한 방법은 UsernamePasswordAuthenticationToken을 사용하는 것이다.
UsernamePasswordAuthenticationToken 상속 구조 - UsernamePasswordAuthenticationToken은 AbstactAuthenticationToken을 상속받고 있다.
- 해당 클래스를 사용하려면 초기화를 위한 UserDetails가 필요하다.
getUsername( ) 메서드
private String getUsername(String token) {
LOGGER.info("[getUsername] 토큰 기반 회원 구별 정보 추출");
String info = Jwts.parser().setSigningKey(secretKey)
.parseClaimsJws(token).getBody().getSubject();
LOGGER.info("[getUsername] 토큰 기반 회원 구별 정보 완료, info : {}", info);
return info;
}
- Jwts.parser( ) 를 통해 secretKey를 설정하고 클레임을 추출하여 토큰을 생성할 때 넣었던 sub 값을 추출한다.
resolveToken( ) 메서드
public String resolveToken(HttpServletRequest request) {
LOGGER.info("[resolveToken] HTTP 헤더에서 Token 값 추출");
return request.getHeader("X-AUTH-TOKEN");
}
- HttpServletRequest를 파라미터로 받아 헤더 값으로 전달된 "X-AUTH-TOKEN" 값을 가져와서 리턴한다.
- 클라이언트가 헤더를 통해 애플리케이션 서버로 JWT 토큰 값을 전달해야 정상적인 추출이 가능하다.
- 헤더의 이름은 임의로 변경이 가능하다.
validateToken( ) 메서드
public boolean validateToken(String token) {
LOGGER.info("[validateToken] 토큰 유효 체크 시작");
try {
Jws<Claims> claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token);
return !claims.getBody().getExpiration().before(new Date());
} catch (Exception e) {
LOGGER.info("[validateToken] 토큰 유효 체크 예외 발생");
return false;
}
}
- 해당 메서드는 토큰을 전달받아 클레임의 유효기간을 체크하고 boolean 타입의 값을 리턴하는 역할을 한다.
JwtAuthenticationFilter 구현
JWT 토큰으로 인증하고 SecurityContextHolder에 추가하는 필터를 설정하는 클래스이다.
JwtAuthenticationFilter 클래스 (OncePerRequestFilter)
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final Logger LOGGER = LoggerFactory.getLogger(JwtAuthenticationFilter.class);
private final JwtTokenProvider jwtTokenProvider;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String token = jwtTokenProvider.resolveToken(request);
LOGGER.info("[doFilterInternal] token 값 추출 완료. token : {}", token);
LOGGER.info("[doFilterInternal] token 값 유효성 체크 시작");
if (token != null && jwtTokenProvider.validateToken(token)) {
Authentication authentication = jwtTokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
LOGGER.info("[doFilterInternal] token 값 유효성 체크 완료");
}
filterChain.doFilter(request, response);
}
}
- 스프링부트에서는 필터를 여러 방법으로 구현이 가능하다.
- 가장 편한 방법은 필터를 상속받아 사용하는 것이다.
- [대표적인 예시] GenericFilterBean 과 OncePerRequestFilter가 있다.
- OncePerRequestFilter 로부터 오버로딩한 doFilterInternal( ) 메서드가 있다.
- doFilter( ) 메서드는 서블릿을 실행하는 메서드이다.
- doFilter( ) 메서드를 기준으로 앞에 작성한 코드는 서블릿이 실행되기 전에 실행되고, 뒤에 작성한 코드는 서블릿이 실행된 후에 실행된다.
- 내부 로직 순서
- JwtTokenProvider를 통해 ServletRequest에서 토큰을 추출한다.
- 토큰에 대한 유효성을 검사한다. [validateToken( ) 메서드]
- 토큰이 유효하다면 Authentication 객체를 생성해서 SecurityContextHolder에 추가하는 작업을 수행한다.
반응형
'Book > 스프링부트 핵심가이드' 카테고리의 다른 글
스프링 시큐리티(Spring Security) 구현(of. SecurityConfiguration) (1) | 2023.12.06 |
---|---|
스프링 시큐리티(Spring Security)와 JWT (2) | 2023.12.05 |
스프링부트를 이용한 서버 간의 통신(of. WebClient) (0) | 2023.12.02 |
댓글