기존 세션 스토리지에서 JWT로 변경하였다. 인증 관련 컨트롤을 서버에서 온전히 처리하는 코드를 만들기 귀찮아서, 그냥 JWT를 사용하기로 하였다. 앞으로 사용자가 많아지면 좀 더 안전한 세션 기반 인증으로 로직을 변경하려한다.
1. UserEntity
이때 UserDetails를 구현하여 User 엔티티를 만드니 내가 온전히 컨트롤할 수 있었다. 아래 Entity 코드에서 유의하여 확인할 점은 UserDetails 인터페이스를 직접 구현하였다는 점이다. 대부분의 포스팅에서 Spring Security에서 제공하는 사용자 엔티티를 확장(extends)하는 방식으로 구현하였는데, 그렇게 구현하면 내가 원하는 필드를 추가하기 어렵고, 특히 원하지 않는 필드가 추가되어 DB에 필요없는 column이 생기면서 개발자가 설계한대로 따르지 않게 되는 문제가 발생한다. 또한 다른 개발자가 해당 코드를 확인하였을 때 상속의 개념으로 해당 엔티티를 확장한 이유에 대해 선뜻 받아들이지 못할 것 같다는 생각이 들었다.
@Entity
@Table(name = "users")
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@SuperBuilder
public class UserEntity extends UpdatableEntity implements UserDetails {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
@Getter
private String id;
@Setter
@Column(unique = true)
private String username;
@Getter
@Column(unique = true)
private String email;
@Setter
@Getter
private String password;
@Setter
@Getter
private LocalDateTime lastLogin;
@Setter
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL, fetch = FetchType.EAGER)
private Set<UserAuthorityEntity> userAuthorities;
@Setter
@Getter
@Column(nullable = true)
private String avatarFilename;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return userAuthorities.stream().map(UserAuthorityEntity::getAuthority).collect(Collectors.toSet());
}
@Override
public String getUsername() {
return email;
}
public String getCustomUsername() {
return username;
}
}
2. UserService
엔티티가 완성되면 getUsername을 그대로 사용하되, email을 대상으로 사용할 수 있게끔 컨트롤할 수 있도록 한다. 아래 서비스 인터페이스를 구현하면 된다. 혹시 모르니 서비스 인터페이스의 loadUserByUsername 함수의 구현 모습을 보여주겠다. 참고하면 좋을 것 같다.
1) interface
//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//
package org.springframework.security.core.userdetails;
public interface UserDetailsService {
UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}
2) implements
@Override
public UserDetails loadUserByUsername(String email) {
return userRepository.findByEmail(email)
.orElseThrow(() -> new UsernameNotFoundException("User not found with email: " + email));
}
3. JwtFilter
SecurityContext에 올라가는 Authentication에 Principal을 User를 그대로 사용할 수 있게 되었고, 만약 원한다면 JWT Provider에 userRepository로부터 데이터를 그대로 가져올 수도 있게 되었다.
1) jwt filter
public class JwtFilter extends UsernamePasswordAuthenticationFilter {
private final JwtProvider jwtProvider;
public JwtFilter(AuthenticationManager authenticationManager, JwtProvider jwtProvider) {
super.setAuthenticationManager(authenticationManager);
this.jwtProvider = jwtProvider;
}
@Override
public Authentication attemptAuthentication(
HttpServletRequest request,
HttpServletResponse response
) throws AuthenticationException {
try {
SignInRequestDto signInRequestDto = new ObjectMapper()
.readValue(request.getInputStream(), SignInRequestDto.class);
return getAuthenticationManager().authenticate(
new UsernamePasswordAuthenticationToken(
signInRequestDto.getEmail(),
signInRequestDto.getPassword(),
new ArrayList<>()
)
);
} catch(IOException e) {
throw new RuntimeException(e);
}
}
@Override
protected void successfulAuthentication(
HttpServletRequest request,
HttpServletResponse response,
FilterChain chain,
Authentication authResult
) throws IOException {
UserEntity user = (UserEntity)authResult.getPrincipal();
String token = jwtProvider.createToken(user, 5184000);
SignInResponseDto responseDto = new SignInResponseDto();
responseDto.setToken(token);
response.addHeader("Authorization", "Bearer " + token);
response.setContentType("application/json");
new ObjectMapper().writeValue(response.getWriter(), responseDto);
}
}
2) jwt provider
public class JwtProvider {
public static final String AUTHORITIES_KEY = "authorities";
public static final String USERNAME_KEY = "username";
public static final String AUTHORITIES_DELIMITER = ",";
private final Key key;
public JwtProvider(Environment env) {
String secret = env.getProperty("jwt.secret");
this.key = Keys.hmacShaKeyFor(Decoders.BASE64.decode(secret));
}
public String createToken(UserEntity user, Integer tokenValidityInSeconds) {
String authorities = user.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(AUTHORITIES_DELIMITER));
return Jwts.builder()
.setSubject(user.getId())
.claim(AUTHORITIES_KEY, authorities)
.claim(USERNAME_KEY, user.getCustomUsername())
.setExpiration(new Date(System.currentTimeMillis() + tokenValidityInSeconds * 1000))
.signWith(this.key, SignatureAlgorithm.HS512)
.compact();
}
}
4. 추가 코드(config, DTO, signIn 코드)
아래 코드는 혹여나 내용은 관심없고 코드만 필요한 사람을 위해 함께 첨부해놓겠다. 해당 refer를 통해 재밌는 프로젝트가 많이 생겨나면 좋을 것 같다😊👍
1) Config
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtProvider jwtProvider;
@Bean
public SecurityFilterChain securityFilterChain(
HttpSecurity http,
AuthenticationManager authenticationManager
) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.addFilterBefore(jwtFilter(authenticationManager), UsernamePasswordAuthenticationFilter.class)
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.headers((headers) -> headers.frameOptions(FrameOptionsConfig::disable));
return http.build();
}
@Bean
public AuthenticationManager authenticationManager(
AuthenticationConfiguration authenticationConfiguration
) throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
private JwtFilter jwtFilter(AuthenticationManager authenticationManager) {
JwtFilter jwtFilter = new JwtFilter(authenticationManager, jwtProvider);
jwtFilter.setFilterProcessesUrl("/api/user/signin");
return jwtFilter;
}
}
2) DTO
@Getter
@Setter
public class SignInRequestDto {
@NotBlank
@Size(min = 3, max = 50)
private String email;
@NotBlank
@Size(min = 3, max = 100)
private String password;
}
@Getter
@Setter
public class SignInResponseDto {
private String token;
}
3) signIn
해당 프로젝트는 MSA를 기반으로 구현되었기 때문에 아래 코드는 spring API gateway에서 사용 가능한 코드이다. 참고하길 바란다. 만약 권한 관리 관련 filter가 필요하다면 가장 아래 있는 filter를 사용하면 된다.
@Slf4j
@Component
public class AuthorizationHeaderFilter extends AbstractGatewayFilterFactory<AuthorizationHeaderFilter.Config> {
private static final String AUTHORITIES_KEY = "authorities";
private static final String USERNAME_KEY = "username";
private final Key key;
@Getter
@Setter
public static class Config {
private String baseMessage;
private boolean preLogger;
private boolean postLogger;
}
public AuthorizationHeaderFilter(Environment env) {
super(Config.class);
String secret = env.getProperty("jwt.secret");
this.key = Keys.hmacShaKeyFor(Decoders.BASE64.decode(secret));
}
@Override
public GatewayFilter apply(Config config) {
return (exchange, chain) -> {
ServerHttpRequest request = exchange.getRequest();
ServerHttpResponse response = exchange.getResponse();
log.info("Auth Filter baseMessage: {}", config.getBaseMessage());
if (config.isPreLogger()) {
log.info("Auth PRE Filter: request id -> {}", request.getPath());
}
if (request.getHeaders().containsKey(HttpHeaders.AUTHORIZATION)) {
String token = resolveToken(Objects.requireNonNull(
request.getHeaders().get(HttpHeaders.AUTHORIZATION)
).getFirst());
if (StringUtils.hasText(token) && isValidateToken(token)) {
Claims claims = Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody();
String userId = claims.getSubject();
String authorities = claims.get(AUTHORITIES_KEY).toString();
String username = claims.get(USERNAME_KEY).toString();
ServerHttpRequest newRequest = request.mutate()
.header("UserId", userId)
.header("Username", username)
.header("Authorities", authorities)
.build();
exchange = exchange.mutate().request(newRequest).build();
} else {
return onError(exchange, "token is not valid");
}
}
return chain.filter(exchange).then(Mono.fromRunnable(()->{
if (config.isPostLogger()) {
log.info("Auth POST Filter: response code -> {}", response.getStatusCode());
}
}));
};
}
private Mono<Void> onError(ServerWebExchange exchange, String err) {
ServerHttpResponse response = exchange.getResponse();
response.setStatusCode(HttpStatus.UNAUTHORIZED);
DataBuffer buffer = exchange.getResponse()
.bufferFactory()
.wrap(err.getBytes(StandardCharsets.UTF_8));
return response.writeWith(Flux.just(buffer));
}
private String resolveToken(String authorizationHeader) {
if (StringUtils.hasText(authorizationHeader) && authorizationHeader.startsWith("Bearer ")) {
return authorizationHeader.substring(7);
}
return null;
}
private boolean isValidateToken(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;
}
}
@Slf4j
@Component
public class AdminAuthorityFilter extends AbstractGatewayFilterFactory<AdminAuthorityFilter.Config> {
@Getter
@Setter
public static class Config {
private String baseMessage;
private boolean preLogger;
private boolean postLogger;
}
public AdminAuthorityFilter() {
super(AdminAuthorityFilter.Config.class);
}
@Override
public GatewayFilter apply(Config config) {
return (exchange, chain) -> {
ServerHttpRequest request = exchange.getRequest();
ServerHttpResponse response = exchange.getResponse();
log.info("Admin Filter baseMessage: {}", config.getBaseMessage());
if (config.isPreLogger()) {
log.info("Admin PRE Filter: request id -> {}", request.getPath());
}
String authority = Objects.requireNonNull(request.getHeaders().get("Authorities")).getFirst();
if (!StringUtils.hasText(authority) || !authority.equals("ADMIN")) {
return onError(exchange, "This is not ADMIN account");
}
return chain.filter(exchange).then(Mono.fromRunnable(()->{
if (config.isPostLogger()) {
log.info("Admin POST Filter: response code -> {}", response.getStatusCode());
}
}));
};
}
private Mono<Void> onError(ServerWebExchange exchange, String err) {
ServerHttpResponse response = exchange.getResponse();
response.setStatusCode(HttpStatus.UNAUTHORIZED);
DataBuffer buffer = exchange.getResponse()
.bufferFactory()
.wrap(err.getBytes(StandardCharsets.UTF_8));
return response.writeWith(Flux.just(buffer));
}
}
5. 마치며
1) 세션 기반 인증
해당 코드를 구현하기 전에 세션 기반 인증 방식으로 인증 로직을 가져간 적도 있다. 중요하다고 생각하는 점은 메모리에 올라간(인증 pool) 세션을 어떻게 식별하여 가져올 것인가이다. 즉, 세션을 controller에서 사용하는 것이다. 이때 사용할만한 것이 AuthenticationPrincipal 어노테이션이다. 이 어노테이션은 스레드 단위로 사용자 인증 세션 정보 객체를 저장하고 있는 SecurityContext에 있는 사용자 인증 정보인 Principal을 가져다가 사용하는 것이라는 점이 중요한 것 같다. 이를 알고 있으면 필요한 정보를 SecurityContext로부터 가져올 수 있다.
@Target({ElementType.PARAMETER, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@AuthenticationPrincipal
public @interface LoginUser {
}
AuthenticationPrincipal의 경우 다음 글을 참고하면 이해가 쉬울 것 같다. 잘 정리되어 있는 글을 첨부한다.
2) JWT와 refresh 토큰
이 내용에 대해선 할 말이 조금 있다. 항상 의문이었던 것이 refresh 토큰을 사용하면 기존에 사용하던 순수 JWT 방식보다 좀 더 안전한 방식으로 JWT를 구현할 수 있기 때문에, 어느 정도 JWT의 구현 방식에 refresh 토큰의 관리를 추가하여 커스텀된 JWT를 사용하면 좋다라는 글을 여기저기서 많이 확인할 수 있었다(OAuth를 제외하고). 그러나 해당 의견에 대해선 동의할 수 없다라는 것이 나의 생각이다.
1. refresh 토큰을 사용하면 짧은 access token을 사용하여 어느 정도 보안을 유지할 수 있다?
좀 더 안전하다는 의미가 access token을 탈취 당한 경우를 생각하고, 짧은 expired time을 제공함으로써 해커에게 분석할 시간을 늦출 수 있다는 점은 어느정도 동의한다. 하지만 해커도 바보가 아니다. 당연히 비정상적인 행동을 지속하지 않으면서 분석하려는 움직임을 보일 것이 당연하다.2. refresh 토큰을 서버에서 컨트롤하기 때문에 해당 refresh 토큰을 가지고 해커의 침입에 방지할 수 있는 기회가 생긴다?
해당 방식은 차라리 세션 방식에서 좀 더 유리하다. 결국 access token의 expired time은 어느정도 남아있을 것이고, 해커가 access token을 탈취한 방법이 이후에 바로 수정되지 않을 가능성 역시 존재하기 때문이다. 그 전에 해커는 충분히 분석을 마친 후 치명적인 공격 한 방을 노릴 가능성이 높다.3. 그럼 refresh 토큰의 expired time을 최대한 길게 제공하여 해당 기능을 구현하면 되지 않느냐?
그럼 결국 refresh 토큰의 보안성 역시 높아야 한다. 그런데 이 refresh 토큰은 결국 서버에 저장될 뿐만 아니라 사용자 PC에도 저장된다. 그럼 사용자 PC에 저장된 token 역시 격리된 공간에 저장되어야 한다는 뜻인데, 그럼 인증 프로세스 때문에 사용자는 계속해서 불필요한 행위를 계속해야한다. access 토큰의 expired time을 길게 줘도 동일하다. 사실 이 부분은 세션 방식도 동일하게 위험한 상황이긴 하지만 생각해보면 굳이 refresh 토큰을 가지고 구현할 필요가 더더욱 없어진다. 결국 jwt로 인증 프로세스를 구현하는 이유는 좀 더 구현이 쉽고 간단하기 때문인데, refresh 토큰 기능이 추가되는 순간 개발자가 구현해야하는 부분들이 오히려 많아진다.4. 사용자 PC에 접근이 가능하다는 점에서 사실 이미 상황이 끝난 것 아니냐, 네트워크 스푸핑을 통한 access 토큰 탈취에 대한 상황만 놓고 보는 것이 옳다.
이런 상황을 가정한다면, 2가지 조건이 붙어야할 것 같다(내가 아는 선에선). 첫 번째로 같은 로컬 네트워크에 묶여 통신하는 상황, 두 번째로 보안 프로토콜(https, wss) 등을 사용하지 않는 상황(http, ws)이다. 즉, 4번 질문의 마지막 결론과 동일한 결론에 도달한다.
대부분의 JWT 보안과 관련된 블로그 내용을 보면 JWT 기반 인증을 통해 보안을 어느정도 부여하면서 인증 프로세스를 구현할 수 있다는 내용이 대부분이다. 하지만 내 생각은 다르다. 내가 알고 있는 해커들은 적어도 바보는 아니다. 오히려 굉장히 똑똑한 사람들이 많다. JWT 기반 인증 프로세스를 통해 얻을 수 있는 이점은 개발의 편의성(인증의 책임을 온전히 클라이언트에게 맡기기 때문), DB IO를 줄일 수 있다는 점(이마저도 1개의 데이터를 1번 조회하는데서 발생하는 IO를 줄이는 것이고, 메모리 캐쉬를 쓴다면 큰 문제도 아니다), 그리고 대규모 트래픽을 위한 복잡한 시스템에서의 개발이 편리해진다는 점(사실 이 부분이 좀 큰 것 같음) 정도가 있는 것 같다.
물론 JWT를 사용한다고 해서 잘못된 것은 아니라 생각한다. JWT 토큰을 가지고 인증 프로세스를 구현하는 것이 갖는 장점 역시 존재한다고 생각한다. 하지만, JWT를 구현하면서 refresh 토큰을 함께 사용하기 때문에 보안성이 좋아진다라는 의견엔 동의할 수 없고, refresh 토큰 기능을 구현하는 것이 무슨 의미를 갖는 것인지 잘 모르겠다.
Microsoft에 올라와 있는 실제 access token 및 refresh token의 expire time을 어떻게 설정해놓았는가를 적어놓은 글이다. 확인해보면 access token은 보통 권장되는 30분이 아닌 60일, refresh token은 1년을 기간으로 주어지고 구현을 하라고 설명한다. 즉, UX를 구리지 않게 하기 위한 조치라고 생각된다.
결론은 OAuth는 UX를 위해 사용을 고려하되, 직접 JWT 토큰을 구현하여 서비스를 개발하는 것은 비추천한다.
'scoinone' 카테고리의 다른 글
| jetbrains IDE를 사용한 SSH 원격 개발 시 멀티 포트포워딩 (0) | 2025.09.10 |
|---|---|
| npm, soop-extension 라이브러리 (숲 채팅 크롤링-스크래핑) (0) | 2025.09.10 |
| PR 던진 후 github action 동작 시 secret_key 에러 (0) | 2025.09.09 |
댓글