RedisConfig, RedisService — Refresh Token 저장SecurityConfig, SecurityFilterChainCorsConfigurationSourceBCryptPasswordEncoderencode(), 로그인 비교 matches()JwtAuthenticationFilter — OncePerRequestFilter, SecurityContextHolder| 방식 | 예 |
|---|---|
| BareMetal | 물리 서버에 직접 설치 |
| Hypervisor | VirtualBox, VMWare |
| Container | Docker |
project를 jar로 묶어서 docker image로 만든다.
Refresh Token을 저장할 in-memory DB로 Redis를 쓴다. Redis는 docker hub의 image로 container를 띄워 실행한다.
# Redis
# docker : image(docker hub) - container 실행
# docker run -d --name redis-container -p 6379:6379 redis:7.2
# docker exec -it redis-container redis-cli
# docker run -d --name redis-insight -p 8001:5540 redis/redisinsight:latest
# http://localhost:8001
# host: host.docker.internal, redis
data:
redis:
host: localhost
port: 6379
password:
timeout: 6000ms
의존성은 spring-boot-starter-data-redis를 추가한다.
// inMemory DB : H2, Redis
implementation "org.springframework.boot:spring-boot-starter-data-redis"
redis-cli는 container에 접속해 명령어로 확인하는 방법이다. 화면으로 보려면 redis-insight container를 함께 띄우고 http://localhost:8001에 접속하면 Redis database를 확인할 수 있다. 연결 host는 주석에 적힌 대로 host.docker.internal 또는 redis를 입력한다.
RedisTemplate을 bean으로 등록한다. key와 value 모두 StringRedisSerializer로 지정해서 문자열 그대로 저장되게 한다.
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory rcf) {
System.out.println("debug >>>> redis config RedisConnectionFactory");
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(rcf);
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(new StringRedisSerializer());
return template;
}
}
RedisService는 RT를 저장하고 지운다. key는 "RT:" + email이다.
@Service
@RequiredArgsConstructor
public class RedisService {
// TTL(Time To Live - 유효기간)
private static final long RT_TTL = 60 * 60 * 24 * 7; // 7 days
private final RedisTemplate<String, Object> redisTemplate;
public void saveToken(String email, String rt) {
System.out.println("debug >>>> RedisService save rt token");
redisTemplate.opsForValue()
.set("RT:" + email, rt, RT_TTL, TimeUnit.SECONDS);
}
public void deleteToken(String email) {
System.out.println("debug >>>> RedisService delete rt token");
redisTemplate.delete("RT:" + email);
}
}
TTL이 지나면 Redis가 key를 알아서 지운다. RT_TTL은 초 단위이고 TimeUnit.SECONDS를 같이 넘긴다. JwtProvider의 만료 시간은 ms 단위라서, 같은 7일이어도 숫자가 다르다.
// ms 단위로 계산하는 것이 기본
private final long ACCESS_TOKEN_EXPIRY = 1000L * 60 * 30 ;
private final long REFRESH_TOKEN_EXPIRY = 1000L * 60 * 60 * 24 * 7 ;
직접 만든 JwtFilter와 CorsConfig가 하던 일을 Spring Security로 옮긴다. 기존 클래스는 bean 등록 annotation만 주석 처리했다.
// spring security
implementation "org.springframework.boot:spring-boot-starter-security"
// spring filter 사용으로 JWT 필터 사용 안 함.
// @Component
public class JwtFilter implements Filter{
// spring security 에서 cors 해결
// @Configuration
public class CorsConfig implements WebMvcConfigurer {
SecurityConfig에서 SecurityFilterChain을 bean으로 등록한다.
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
System.out.println("debug >>>> SecurityConfig filterChain");
// cors vs csrf(Cross-Site Request Forgery)
// csrf : 사용자가 의도하지 않은 요청을 해커가 사용자가 요청하는 것처럼 보내는 공격
http
.cors(Customizer.withDefaults())
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> auth
.requestMatchers(
"/users/**",
"/swagger-ui/**",
"/v3/api-docs/**").permitAll()
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
).sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
http.addFilterBefore(
jwtAuthenticationFilter,
UsernamePasswordAuthenticationFilter.class);
return http.build();
}
| 설정 | 내용 |
|---|---|
cors(Customizer.withDefaults()) | bean으로 등록한 CorsConfigurationSource를 사용 |
csrf(csrf -> csrf.disable()) | csrf 방어 비활성화 |
permitAll() | 토큰 없이 통과 |
hasRole("ADMIN") | ROLE_ADMIN 권한이 있어야 통과 |
authenticated() | 인증된 요청만 통과 |
SessionCreationPolicy.STATELESS | 세션을 만들지 않음 |
addFilterBefore() | UsernamePasswordAuthenticationFilter 앞에 JWT 필터 배치 |
csrf는 브라우저가 쿠키를 자동으로 실어 보내는 점을 이용한 공격이다. 지금 구조는 토큰을 Authorization 헤더에 직접 실어 보내고 세션도 쓰지 않아서 비활성화했다.
JwtFilter에서 하던 일이 설정 쪽으로 옮겨간 자리는 이렇다.
| JwtFilter | SecurityConfig |
|---|---|
WHITE_LIST + AntPathMatcher | requestMatchers(...).permitAll() |
OPTIONS 분기에서 CORS 헤더 직접 설정 | requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() + cors() |
| 헤더 없으면 401 반환 | anyRequest().authenticated() |
CorsConfig의 addCorsMappings() 대신 CorsConfigurationSource를 bean으로 등록한다.
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(List.of("http://localhost:3000"));
configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
configuration.setAllowedHeaders(List.of("Authorization", "Content-Type", "Refresh-token"));
configuration.setExposedHeaders(List.of("Authorization"));
configuration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
allowedHeaders는 프론트가 요청에 담아 보낼 수 있는 헤더, exposedHeaders는 프론트가 응답에서 꺼내 읽을 수 있는 헤더다. 로그인 응답의 토큰을 프론트에서 읽어야 하므로 Authorization을 노출한다.
복호화가 가능한지로 구분한다.
| 구분 | 복호화 | 방향 | 용도 | 예 |
|---|---|---|---|---|
| Hashing | X (키 자체가 없음) | 단방향 | 비밀번호 저장 | SHA256, BCrypt |
| Encryption | 키가 있으면 O | 양방향 | 데이터의 은닉 및 전달 | AES, RSA |
Encryption 중 AES는 암호화와 복호화에 같은 키를 쓰는 대칭키 방식이고, RSA는 공개키와 개인키를 나눠 쓰는 비대칭키 방식이다.
비밀번호 해싱에 쓸 PasswordEncoder를 SecurityConfig에 bean으로 등록한다.
// hashing 위한 객체 (복호화 X)
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
저장 전에 비밀번호를 해싱한다.
@Transactional
public UserResponseDTO signUp(UserRequestDTO request) {
System.out.println("debug >>>> user service signUp");
////////////// spring security password hashing add
request = request.toBuilder()
.password(passwordEncoder.encode(request.getPassword()))
.build();
///////////////////////////////
// case 01
UserEntity entity = UserRequestDTO.toEntity(request) ;
return UserResponseDTO.fromEntity(userRepository.save(entity));
}
UserRequestDTO에는 setter가 없다. @Builder(toBuilder = true)로 기존 값을 복사한 builder를 받아 password만 바꾼 새 객체를 만든다.
UserRequestDTO에는 인가에 쓸 role 필드도 추가하고 toEntity()에 넣었다.
// optional (spring security role)
private String role ;
public static UserEntity toEntity(UserRequestDTO request) {
return UserEntity.builder()
.email(request.getEmail())
.password(request.getPassword())
.name(request.getName())
.role(request.getRole())
.build() ;
}
평문으로 저장하던 때는 email과 password를 조건으로 한 번에 조회했다.
// plain text version
UserEntity entity = userRepository
.findByEmailAndPassword(request.getEmail(), request.getPassword())
.orElseThrow(() -> new LoginFailException("SignIn Fail!!"));
해싱 후에는 email로만 찾고 matches()로 비교한다.
// hashing version
// 암호화된 패스워드를 비교하는 구문
UserEntity entity = userRepository
.findById(request.getEmail())
.orElseThrow(() -> new LoginFailException("SignIn Fail!!"));
if (!passwordEncoder.matches(request.getPassword(), entity.getPassword())) {
throw new RuntimeException("Password Not Matches");
}
BCrypt는 같은 입력이라도 해싱할 때마다 다른 값이 나온다. 입력값을 다시 해싱해서 WHERE 조건으로 비교할 수 없으므로, 저장된 해시와 입력값을 matches()에 넘겨 비교한다.
로그인에 성공하면 AT, RT를 발급하고 RT는 Redis에 저장한다.
// 사용자 로그인이 정상적으로 수행되면 token 발급되어야 함.
System.out.println("debug >>>> user service signIn token provider ");
String at = jwtProvider.createAT(entity.getEmail());
String rt = jwtProvider.createRT(entity.getEmail());
// inMemory DB - Redis, H2
// at, rt 담아서 관리 - redis - docker
System.out.println("debug >>>> user service RT redis DB save");
redisService.saveToken(entity.getEmail(), rt);
Map<String, Object> map = new HashMap<>();
map.put("response", UserResponseDTO.fromEntity(entity));
map.put("at", at);
map.put("rt", rt);
return map ;
controller는 두 토큰을 응답 헤더에 담는다.
HttpHeaders headers = new HttpHeaders();
headers.add("Authorization", "Bearer "+(String)(map.get("at")));
headers.add("Refresh-Token", (String)(map.get("rt")));
headers.add("Access-Control-Expose-Headers", "Authorization, Refresh-Token");
return ResponseEntity
.status(HttpStatus.OK)
.headers(headers)
.body( (UserResponseDTO)(map.get("response"))) ;
프론트는 응답 헤더에서 꺼내 localStorage에 저장한다.
localStorage.setItem('at', response.headers.get("Authorization"));
localStorage.setItem('rt', response.headers.get("Refresh-Token"));
OncePerRequestFilter를 상속해서 요청당 한 번 실행되는 필터를 만든다. JwtFilter는 토큰 검증만 했지만, 이 필터는 검증한 토큰의 정보를 SecurityContextHolder에 저장한다.
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
preflight는 CORS 헤더를 직접 넣지 않고 그대로 넘긴다. CORS 처리는 SecurityConfig가 맡는다.
// preflight : get,post -> options 전달이 이루어짐
if("OPTIONS".equalsIgnoreCase(request.getMethod())) {
System.out.println("debug >>>> JwtAuthenticationFilter preflight");
// header set : Origin, Method, Header
// SecurityConfig 통해서 처리 예정
filterChain.doFilter(request, response);
return ;
}
헤더가 없을 때도 달라졌다. JwtFilter는 여기서 401을 세팅하고 끝냈지만, 지금은 인증 정보 없이 다음 필터로 넘긴다. 통과 여부는 SecurityConfig의 authorizeHttpRequests가 판단한다. white list 검사가 필터에서 빠진 것도 같은 이유다.
String header = request.getHeader("Authorization");
System.out.println("debug >>>> JwtAuthenticationFilter header "+header);
if( header == null || !header.startsWith("Bearer ")) {
System.out.println("debug >>>> JwtAuthenticationFilter UNAUTHORIZED ");
filterChain.doFilter(request, response);
return ;
}
토큰이 유효하면 Claims에서 email(subject)과 role을 꺼내 인증 객체를 만든다.
// Claims == JWT 데이터(header, payload, sign)
Claims claims = Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody();
String email = claims.getSubject();
System.out.println("debug >>>> JwtAuthenticationFilter token claims get email : " + email);
// role
String role = claims.get("role", String.class);
System.out.println("debug >>>> JwtAuthenticationFilter token claims role : " + role);
// security 인증정보를 담을 수 있는 객체
// Principal, credential, authorities
// UsernamePasswordAuthenticationToken
// SecurityContextHolder <= user id(email)
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(email,
null,
role != null ?
java.util.List.of(() -> "ROLE_" + role) :
java.util.List.of());
// 요청한 사용자와 인증정보객체를 연동
authenticationToken.setDetails(
new WebAuthenticationDetailsSource().buildDetails(request)
);
// 사용자의 정보를 SecurityContextHolder 저장하고
// 필요한 경우 controller 또는 service에서 로드하여 정보를 비교할 수 있도록
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
System.out.println("debug >>>> JwtAuthenticationFilter security context holder save authenticationToken");
filterChain.doFilter(request, response);
| 인자 | 값 |
|---|---|
| principal | |
| credentials | null (토큰으로 이미 인증) |
| authorities | "ROLE_" + role |
authorities에 ROLE_을 붙이는 이유는 hasRole("ADMIN")이 ROLE_ADMIN을 찾기 때문이다.
필터에서 저장한 인증 정보는 service에서 다시 꺼낼 수 있다. 블로그 작성 시 email을 요청 파라미터가 아니라 토큰에서 가져온다.
////////// email from security context holder
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
String email = auth.getName();
System.out.println("debug >>>> blog service insert params email : " + request.getEmail());
System.out.println("debug >>>> blog service insert SecurityContextHolder email : " + email);
return userRepository.findById(email)
.map(user -> {
BlogEntity blog = blogRepository.save(request.toEntity(user));
return BlogResponseDTO.fromEntity(blog);
})
.orElseThrow(() -> new RuntimeException("Blog Insert Failed!"));
auth.getName()은 인증 객체를 만들 때 principal로 넣은 email을 돌려준다.
로그아웃은 SecurityContextHolder의 email로 Redis의 RT를 지운다.
@PostMapping("/signOut")
public ResponseEntity<?> signOut() {
System.out.println("debug >>>> user controller signOut ");
userService.signOut();
return ResponseEntity.status(HttpStatus.NO_CONTENT).build();
}
public void signOut() {
////////// email from security context holder
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
String email = auth.getName();
System.out.println("debug >>>> user service signOut SecurityContextHolder email : " + email);
redisService.deleteToken(email);
}
프론트는 AT를 헤더에 담아 요청한다. 보낼 body가 없어서 두 번째 인자가 null이다.
const logoutHandler = async (e) => {
await api.post(`/users/signOut`, null, {
headers : {Authorization : at ? at : ""}
})
.then( response => {
console.log(`debug >>>> axios request success` , response);
if(response.status === 204) {
moveUrl('/');
}
})
.catch( error => {
console.log(`debug >>>> axios request error` , error);
});
moveUrl('/');
};
AT는 30분, RT는 7일로 만료 시간이 다르다. AT가 만료되었을 때 RT로 새 AT를 받는 과정을 사용자가 느끼지 못하게 뒤에서 처리하는 것이 Interceptor가 하는 일이다.
delete, update의 debug 출력을 복사해 오면서 insert에도 id를 찍는 줄이 따라 들어왔다. delete, update는 @PathVariable로 id를 받지만 insert의 파라미터는 CommentRequestDTO 하나라서 id라는 변수가 없다.
insert의 id는 저장한 뒤에야 생기므로, 저장 결과인 응답 객체 res를 출력하도록 고쳤다.
CommentResponseDTO res = commentService.insert(request);
System.out.println("debug >>>> comment controller insert response : " + res);
에러가 한 번에 여러 개 떠서 원인을 하나로 보기 어려웠는데, 서로 다른 문제 세 개였다.
첫째, UsernamePasswordAuthenticationToken 생성자에 credentials: null처럼 이름을 붙여 인자를 넘겼다. Java에는 named argument 문법이 없다. 이 줄이 깨지면서 바로 뒤에 넘긴 authorities 쪽 lambda까지 같이 에러가 났다. 이름을 빼고 값만 순서대로 넘기도록 고쳤다.
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(email,
null,
role != null ?
java.util.List.of(() -> "ROLE_" + role) :
java.util.List.of());
둘째, 클래스 본문의 마지막 }가 빠져 있었다. 괄호 개수 자체가 맞지 않는 문제였다.
셋째, 헤더가 없거나 Bearer 로 시작하지 않을 때 filterChain.doFilter()만 호출하고 return이 없었다.
if( header == null || !header.startsWith("Bearer ")) {
System.out.println("debug >>>> JwtAuthenticationFilter UNAUTHORIZED ");
filterChain.doFilter(request, response);
return ;
}
String token = header.substring(7);
doFilter()는 다음 필터를 호출할 뿐 현재 메서드를 끝내지 않는다. 뒤쪽 체인이 처리를 마치고 돌아오면 아래 코드가 이어서 실행되고, header가 null인 상태로 header.substring(7)에 도달해 NPE가 났다.
@Builder만 붙이면 toBuilder()는 생성되지 않는다. @Builder(toBuilder = true)로 지정해야 한다.
@Builder(toBuilder = true)
@Getter
@ToString
@NoArgsConstructor
@AllArgsConstructor
같은 자리에서 builder 체인의 결과를 변수에 대입하지 않았고 .build()도 빠져 있었다. toBuilder()는 원본을 바꾸지 않고 새 builder를 돌려주므로, 대입하지 않으면 해싱 결과가 버려진다. 에러 없이 실행되지만 비밀번호는 평문으로 저장된다.
처음에는 이 해싱 코드를 UserController에 작성했다. 비밀번호 해싱은 비즈니스 로직이라 UserService.signUp()으로 옮기면서 대입과 .build()까지 고쳤다.
////////////// spring security password hashing add
request = request.toBuilder()
.password(passwordEncoder.encode(request.getPassword()))
.build();
///////////////////////////////
BlogService에서 Authentication을 자동 import했더니 Spring Security의 클래스가 아니라 Couchbase 설정 프로퍼티의 내부 클래스가 들어왔다. 이름만 같은 다른 클래스라서 getName()이 없다는 에러가 났다.
import org.springframework.security.core.Authentication;
콘솔에 reportAllChanges, startTime 관련 에러가 찍혀서 앱 문제인지 확인했다. 브라우저 확장 프로그램이 페이지에 주입한 스크립트에서 난 에러였다. CRA 앱의 reportWebVitals()는 인자 없이 호출되어 아무 동작도 하지 않으므로 관계없다.