안녕하세요. 코딩 신생아 입니다.
2달 전에 oauth 구글 로그인과 네이버 로그인의 구조적인 차이로 인한 pr 리뷰 후, 임시방편으로 두 개의 로그인 방식마다 따로 DefaultOAuth2UserNaver , DefaultOAuth2UserGoogle 클래스를 따로 만들어 해결하는 방법을 제시하였는데,
이를 해결할 수 있는 좀 더 나은 방법을 찾기 위해 spring security , jwt 기본으로 돌아가 보려고합니다.
우선, spring security가 무엇이고 개념에 대해 차근차근 정리해보겠습니다.
spring security란
스프링 시큐리티는 스프링 기반의 어플리케이션의 인증, 인가, 권한 을 담당해주는 하위 프레임워크이다.
스프링 시큐리티는 필터 기반으로 동작하는데,
DelegatingFilterProxy 이 감싸는 FilterChainProxy 라는 필터를 통해 Security Filter Chain 에 의한 security filter 인스턴스들에 위임되어 작동된다. 되게 복잡하다.
SecurityFilterChain에 존재하는 Security Filter들에는 31개가 존재하는데,
- ChannelProcessingFilter
- ConcurrentSessionFilter
- WebAsyncManageIntegrationFilter
- SecurityContextPersistenceFilter
- HeaderWriterFilter
- CorsFilter
- CsrfFilter
- LogoutFilter
- OAuth2AuthorizationRequestRedirectFilter
- Saml2WebSsoAuthenticationRequestFilter
- X509AuthenticationFilter
- AbstractPreAuthenticatedProcessingFilter
- CasAuthenticationFilter
- OAuth2LoginAuthenticationFilter
- Saml2WebSsoAuthenticationFilter
- UsernamePasswordAuthenticationFilter
- ConcurrentSessionFilter
이 중 UsernamePasswordAuthenticationFilter에 대해 정리해보자.
- Client가 요청을 보내면, servlet filter에 의해서 security filter로 security작업이 위임되고 여러 security filter 중에서 AuthenticationFilter(UsernamePasswordAuthenticationFilter인데 편의성 이렇게 부름) 에서 인증을 처리한다.
- UsernamePasswordAuthenticationFilter는 servlet요청 객체(HttpServletRequest)에서 username과 password를 추출해 UsernameAuthenticationToken(이하 인증 객체) 을 생성한다.
- AuthenticationFilter는 AuthenticationManager(구현체 : ProviderManager)에게 인증 객체를 전달한다.
- ProviderManager는 인증을 위해 AuthenticationProvider에게 인증 객체를 전달한다.
- AuthenticationProvider는 전달 받은 객체 정보(일반적으로 사용자 아이디)를 UserDetailsService에 넘겨주고,
- UserDetailsService는 전달 받은 객체 정보를 통해 DB에서 알맞은 사용자를 찾고 이를 기반으로 UserDetails객체를 만든다.
- 만든 UserDetails 객체를 AuthenticationProvider에 전달한다.
- AuthenticationProvider는 전달 받은 UserDetails객체를 인증해서 성공하면, ProviderManager에게 권한을 담은 검증된 인증 객체를 전달한다.
- ProviderManager는 검증된 인증 객체를 AuthenticationFilter에게 전달하고,
- AuthenticationFilter는 검증된 인증 객체를 SecurityContextHolder의 SecurityContext에 저장한다.
jwt로 구현
우선 폴더 구조 부터 살펴보자.
되게 복잡해 보인다. 하나씩 살펴보는 것은 나중에 하고, 우선,
필터를 정의한 부분이 어딘지 -> SecurityConfig.java
token(accesstoken, refreshtoken) 을 어떻게 생성하는지 -> JwtProvider.java
헤더에 담긴 Bearer token형태에서 사용자를 어떻게 인증하는지 -> JwtAuthFilter.java
refreshtoken 갱신 로직이 어떻게 되는지 -> RefreshToken.java
에 대해서 집중적으로 정리해보자.
├── LoginApplication.java
├── api
│ ├── common
│ │ ├── entity
│ │ │ └── RegModDt.java
│ │ └── response
│ │ ├── entity
│ │ │ └── ApiResponseEntity.java
│ │ └── enums
│ │ ├── ResponseEnum.java
│ │ └── ResponseKeyEnum.java
│ ├── login
│ │ ├── application
│ │ │ ├── LoginService.java
│ │ │ └── impl
│ │ │ └── LoginServiceImpl.java
│ │ ├── controller
│ │ │ └── LoginController.java
│ │ ├── dto
│ │ │ ├── request
│ │ │ │ └── LoginRequestDTO.java
│ │ │ └── response
│ │ │ └── LoginResponseDTO.java
│ │ └── exception
│ │ ├── LoginException.java
│ │ └── LoginExceptionResult.java
│ ├── token
│ │ ├── application
│ │ │ ├── RefreshTokenService.java
│ │ │ └── impl
│ │ │ └── RefreshTokenServiceImpl.java
│ │ ├── controller
│ │ │ └── RefreshTokenController.java
│ │ ├── dto
│ │ │ ├── request
│ │ │ │ └── RefreshTokenRequestDTO.java
│ │ │ └── response
│ │ │ └── RefreshTokenResponseDTO.java
│ │ ├── exception
│ │ │ ├── RefreshTokenException.java
│ │ │ └── RefreshTokenExceptionResult.java
│ │ └── vo
│ │ ├── RedisConfig.java
│ │ └── RefreshToken.java
│ └── user
│ ├── application
│ │ ├── UserAddService.java
│ │ ├── UserDelService.java
│ │ ├── UserGetService.java
│ │ └── impl
│ │ ├── UserAddServiceImpl.java
│ │ ├── UserDelServiceImpl.java
│ │ └── UserGetServiceImpl.java
│ ├── controller
│ │ ├── RegisterController.java
│ │ └── UserController.java
│ ├── domain
│ │ ├── entity
│ │ │ ├── User.java
│ │ │ └── value
│ │ │ ├── LoginInfo.java
│ │ │ ├── RoleInfo.java
│ │ │ └── UserInfo.java
│ │ └── repository
│ │ └── UserRepository.java
│ ├── dto
│ │ ├── request
│ │ │ └── UserAddRequestDTO.java
│ │ └── response
│ │ └── UserGetResponseDTO.java
│ ├── enums
│ │ ├── RoleName.java
│ │ └── converter
│ │ └── RoleNameConverter.java
│ └── exception
│ ├── UserException.java
│ └── UserExceptionResult.java
├── config
│ ├── exception
│ │ ├── common
│ │ │ ├── ApiExceptionAdvice.java
│ │ │ ├── ApiExceptionEntity.java
│ │ │ └── enums
│ │ │ └── ApiExceptionEnum.java
│ │ ├── login
│ │ │ └── LoginApiExceptionAdvice.java
│ │ ├── token
│ │ │ └── RefreshTokenApiExceptionAdvice.java
│ │ └── user
│ │ └── UserApiExceptionAdvice.java
│ ├── log
│ │ └── CustomHighlightConverter.java
│ ├── security
│ │ ├── SecurityConfig.java
│ │ ├── filter
│ │ │ └── JwtAuthFilter.java
│ │ ├── handler
│ │ │ ├── CustomAccessDeniedHandler.java
│ │ │ └── CustomAuthenticationEntryPointHandler.java
│ │ └── provider
│ │ └── JwtProvider.java
│ └── web
│ └── WebConfig.java
└── util
└── jwt
└── JwtUtil.java
필터를 정의한 부분이 어딜까?
앞서 말했다시피, spring security 는 필터 기반으로 작동된다. 이에 해당하는 부분은
- SecurityConfig.java
// before filter
http.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
// exception handler
http.exceptionHandling(conf -> conf
.authenticationEntryPoint(customAuthenticationEntryPointHandler)
.accessDeniedHandler(customAccessDeniedHandler)
);
UsernamePasswordAuthenticationFilter.class가 동작하기 전, JwtAuthFilter가 동작하도록 하였다.
JwtAuthFilter는 토큰이 있는 경우, 토큰 내부에서 subject 에 저장된 id값을 추출한다. 해당 id 값을 통해 UsernamePasswordAuthenticationToken을 만들어 이를 SpringSecurityContextHolder (threadlocal 기반으로 같은 스레드 내에서는 전역 번수 처럼 사용된다. 메모리 상에 위치) 에 저장하고 다음 필터에 이를 반환한다.
UsernamePasswordAuthenticationToken 부분의 주요 필드는 principal, credentials로 principal은 유저의 아이디고, credentials는 유저의 패스워드를 사용한다.
토큰은 어떻게 생성될까?
우선 간단하게 왜 토큰 인증 방식을 사용하는지 정리해보자.
보통 서버가 클라이언트 인증을 확인하는 방식으로는
- 쿠키
- 세션
- 토큰
이렇게 세 가지 방식이 있다.
쿠키
key-value 형식의 문자열 덩어리이다.
특정 웹 사이트를 클라이언트가 방문할 경우, 그 사이트가 사용하고 있는 서버를 통해 클라이언트의 브라우저에 설치되는 기록 정보 파일로, 고유 정보 식별이 가능하다.
서버가 클라이언트 측에 저장하고 싶은 정보를 응답 헤더의 Set-Cookie에 담고,
클라이언트가 요청을 보낼 때마다 저장된 쿠키를 요청 헤더의 Cookie에 담아 보낸다.
쿠키 방식의 단점
쿠키 방식은 보안에 취약하다. 쿠키 값을 그대로 보내기 때문에 유출, 조작 당할 위험이 존재한다.
또한 웹 브라우저마다 쿠키에 대한 지원 형태가 다르기 때문에 브라우저간 공유가 불가능하다.
추가로 쿠키 사이즈가 커질수록 네트워크 부하가 심해진다.
세션
쿠키는 값을 그대로 보낸다는 단점 때문에 등장한 방식이다.
민감 정보(유출되면 안된느 정보)를 서버에서 세션 아이디와 매핑해서 저장하고 클라이언트와 서버는 세션 아이디를 쿠키에 저장해서 전송하며 주고받는다.
세션 방식의 단점
세션 방식은 노출되어도 해당 값이 민감 정보를 가지고 있지 않아 괜찮지만,
문제는 해커가 세션 아이디를 탈취해 클라이언트로 위장하는 경우이다.
추가로 서버에서 세션 저장소를 따로 마련하므로, 요청이 많아지면 서버 부하가 심해진다.
토큰
토큰 기반 인증 방식에서는 클라이언트가 서버에 접속을 하면, 서버에서 해당 클라이언트가 인증되었다는 토큰을 부여한다.
클라이언트는 받은 토큰을 다시 서버에 요청할 때 헤더에 심어서 보내고, 서버에서는 클라이언트로부터 받은 토큰을 인증하는 절차를 걸친다. 세션 기반 인증과 달리 상태를 유지하지 않으므로 Stateless한 특징을 가진다.
Jwt
jwt는 인증에 필요한 정보들을 암호화시킨 json 토큰이다.
Base64 URL - safe Encode를 통해 인코딩해 직렬화하고, 개인키를 통한 전자서명도 들어있다.
헤더, 페이로드, 서명으로 나누어지는데,
- 헤더
- alg : 서명 암호화 알고리즘
- typ: 토큰 유형
- 페이로드
- 토큰에서 사용할 정보의 조각들인 claim이 담겨있다. 정해진 데이터타입은 없지만 대표적으로 세 가지로 나뉜다.
- Registered claims : 미리 정의된 클레임
- iss 발행자
- exp 만료시간
- sub 제목
- iat 발행시간
- jti JWT ID
- public claims : 사용자가 정의할 수 있는 클레임
- private claims : 해당 당사자들 간 정보 공유를 위해 만들어진 것으로 해당 유저를 특정할 수 있지만, 공개되도 상관없는 정보를 담는다.
- Registered claims : 미리 정의된 클레임
- 토큰에서 사용할 정보의 조각들인 claim이 담겨있다. 정해진 데이터타입은 없지만 대표적으로 세 가지로 나뉜다.
- 서명
- 해커가 페이로드를 수정해도, 서명 부분은 비밀키를 통해 암호화 된 값이기 때문에, 서버는 이를 통해 인증을 수행한다.
- (헤더 + 페이로드) + 서버 키
토큰 방식의 단점
토큰이 탈취당하면 대처하기 어려워 refreshtoken을 사용한다.
payload 자체는 암호화되지 않기에 유저의 중요한 정보는 담을 수 없다.
accesstoekn, refreshtoken
accesstoken은 사용자 인증/인가 를 위한 토큰이다. 만료시간을 짧게 30분 정도로 잡는다.
refreshtoken은 accesstoken의 만료시간이 다 되면 새로운 토큰을 발급해주는 토큰이다. accesstoken을 탈취 당해도 만료시간이 지나면 새로운 토큰을 발급받아야 되기 때문에 보안성을 조금 강화시킨다. accesstoken을 새로 발급하기 위한 수단이므로, 만료시간을 14일 정도로 잡는다.
- 먼저 access token 부터 만들어보자
현재는 claims에 아무 값이 안 들어가고, subject에 사용자의 id만 들어가는 형태이다.
/**
* access token 생성
*
* @param id token 생성 id
* @return access token
*/
public String generateAccessToken(final long id) {
return generateAccessToken(String.valueOf(id), new HashMap<>());
}
/**
* access token 생성
*
* @param id token 생성 id
* @param claims token 생성 claims
* @return access token
*/
public String generateAccessToken(final String id, final Map<String, Object> claims) {
return doGenerateAccessToken(id, claims);
}
/**
* JWT access token 생성
*
* @param id token 생성 id
* @param claims token 생성 claims
* @return access token
*/
private String doGenerateAccessToken(final String id, final Map<String, Object> claims) {
return Jwts.builder()
.setClaims(claims)
.setSubject(id)
.setIssuedAt(new Date(System.currentTimeMillis()))
.setExpiration(new Date(System.currentTimeMillis() + JWT_TOKEN_VALID)) // 30분
.signWith(key)
.compact();
}
- refresh token 를 만들어보자
현재는 claims에 아무 값이 안 들어가고, subject에 사용자의 id만 들어가는 형태이다.
/**
* refresh token 생성
*
* @param id token 생성 id
* @return refresh token
*/
public String generateRefreshToken(final long id) {
return doGenerateRefreshToken(String.valueOf(id));
}
/**
* refresh token 생성
* @param id token 생성 id
* @return refresh token
*/
private String doGenerateRefreshToken(final String id) {
return Jwts.builder()
.setSubject(id)
.setExpiration(new Date(System.currentTimeMillis() + (JWT_TOKEN_VALID * 2) * 24)) // 24시간
.setIssuedAt(new Date(System.currentTimeMillis()))
.signWith(key)
.compact();
}
이를 구현해서 정상적으로 토큰 정보가 출력되는 것을 볼 수 있다.
accesstoken 의 기간을 짧게 잡되, refreshtoken이 있으면 accesstoken을 클라이언트가 알아서 재발급하도록 한다. 이때 refreshtoken을 클라이언트가 가지고 있어야할텐데, 어디에 저장할까?
Front 프론트가 저장!
- Local storage
local storage는 자바스크립트로 접근이 쉬워 xss 공격에 취약하고 보안상 문제가 많다.
XSS 공격
크로스 사이트 스크립팅(Cross Site Scripting, XSS).
공격자가 상대방의 브라우저에 스크립트가 실행되도록 해 사용자의 세션을 가로채거나, 웹사이트를 변조하거나, 악의적 콘텐츠를 삽입하거나, 피싱 공격을 진행하는 것
- cookie
httponly와 secure 옵션을 사용하고 CSRF 공격에 대비르 하면 어느정도 보안을 할 수 있다.
CSRF 공격
원클릭 공격 또는 세션 라이딩(Cross-Site Request Forgery).
CSRF는 링크 또는 스크립트를 사용하여 사용자가 인증된 대상 사이트로 원하지 않는 HTTP 요청을 전송.
Back 백엔드가 저장!
- Session
세션에 저장하고 세션 만료 주기를 늘리는 방식. 우선 내가 구현한 부분은 세션을 사용하지 않아 적합하지 않다
- DB
refresh token을 데이터베이스에 저장후 ,index 값을 쿠키나 로컬 스토리지에 저장하는 방법.
Refreshtoken + redis
도커로 redis를 띄웠기 때문에, 아래 명령어로 redis에 접속한다.
docker exec -it [도커 컨테이너 id] redis-cli
/refresh-token 으로 기존 refreshtoken과 함께 요청을 보내면 accesstoken과 refreshtoken이 재발급 되는데, 이는 문제점이 있다.
refreshtoken 이 탈취되면 해커가 accesstoken, refreshtoken을 알 수 있다.
이를 위해서
- 서버 측 리프레시 토큰 저장: 클라이언트에 리프레시 토큰 전체를 노출하지 않고 서버 측 세션과 연결된 식별자만 제공할 수 있고,
- IP 주소 검증: 리프레시 토큰 사용 시 클라이언트의 IP 주소가 이전과 동일한지 확인할 수도 있다.
후기
다음에는 RTR(Refresh token rotation) 에 대해 가상 시나리오를 여러개 가정하여, 보안적인 부분에서 이를 해결하는 과정을 다루려고 한다.
그리고 시간이 된다면, 프론트 혹은 백엔드에 refreshtoken을 저장하는 방식과 ip주소 검증도 다루려고 한다.
참고
[Spring Security의 구조 및 처리 과정 알아보기]
[Spring Security + JWT를 통해 프로젝트에 인증 구현하기]
'기록 > 스프링' 카테고리의 다른 글
스프링이 클라이언트의 요청을 처리하는 전반적인 로직 (servlet , dispatcher servlet, interceptor, filter, aop) (0) | 2025.03.10 |
---|---|
[Intellij/트러블슈팅] 프로젝트 폴더 안보임.. (0) | 2025.01.25 |