- Load Balancer : Client 입장에서는 하나의 API를 호출하는 것 같지만
안쪽으로는 API 처리하는 것을 분산해서 처리해줌
- 서버의 개수만큼 세션의 개수가 늘어남
: 로그인을 다시 해야하는 문제가 발생
1) Sticky Session : Client마다 요청 Server 고정
ex) Client1은 항상 Server1에 요청
클라이언트가 항상 로그인되어 있는 것이 아님
-> 로드 밸런서는 균등하게 처리를 위함인데 하나에 고정되면 그 역할이 사라짐
-> 매핑 정보가 필요함
2) 세션 저장소 생성
-> 서버가 세션 저장소를 저장 또는 조회함.
이전) 로그인 정보가 세션/서버에 저장될 때는 클라이언트에 저장하기 전에
Secret Key를 통해 암호화를 시킴
- 암호화된 JWT를 클라이언트가 가지고 있다가 서버에 요청을 보낼 때 JWT를 같이 보내면,
그 내용을 가지고 서버가 이 사용자구나라고 판단
- 이때 복호화하는 과정에서 JWT가 변경/위조가 되었는지 Secret key를 통해 검증
Client가 username, password로 로그인 성공 시
a. "로그인 정보" -> JWT로 서버에서 암호화 (Secret Key 사용)
Sample
b. JWT를 Client 응답에 전달
c. Client에서 JWT 저장 (쿠키, Local storage 등)
Client에서 JWT 통해 인증방법
a. JWT를 API 요청 시마다 Header 에 포함
ex) HTTP Headers
Content-Type: application/json
Authorization: Bearer <JWT>
...
b. Server
<1> Client가 전달한 JWT 위조 여부 검증
(Secret Key 사용(복호화하면서))
<2> JWT 유효기간이 지나지 않았는 지 검증
: JWT가 유효기간이 지난 토큰은 로그인이 풀림, 더이상 유효 X
<3> 검증 성공 시,
: 원래는 스프링 서버세션에서 로그인 정보를 UserDetailsImpl 만들어서 사용했었음.
(서버 세션 대신에 JWT를 사용하고 있음)
ex) GET /api/products : JWT 보낸 사용자의 관심상품 목록 조회{
"alg": "HS256",
"typ": "JWT"
}
{
"sub": "1234567890",
"username": "제이홉",
"admin": true
}
서버에서 변조됐는지 판단하는 사인
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret)
- 원래는 클라이언트에서 요청 시에 ProductController로 가거나
스프링 시큐리티가 중간에서 로그인 과정 처리함
- 로그인 처리는 예외 처리하고 FormLoginFilter로
(이는 JWT만들어지기 전이기 때문에)
DB에서 체크 후 로그인 성공하면 JWT 생성
JWT 저장
- 관심상품 목록 조회 JWT 포함하여 API 요청.
예전에는 세션에서 로그인 되었는지 확인하고 ProductController 넘겨진 것을
JwtAuthFilter가 그 역할을 함.
인증 성공하면 조회 가능
JWTAuthFilter : API 요청 Header 에 전달되는 JWT 유효성 인증
<1> 모든 API 에 대해 JWTAuthFilter 가 JWT 확인
<2> 로그인 전 허용이 필요한 API 는 예외처리 필요 ⇒ FilterSkipMatcher
FormLoginFilter : 회원 폼 로그인 요청 시 username / password 인증
(FormLoginFilter와 비슷)
Authorization: BEARER <JWT>
ex)
Authorization: BEARER eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJzcGFydGEiLCJVU0VSTkFNRSI6IuultO2DhOydtCIsIlVTRVJfUk9MRSI6IlJPTEVfVVNFUiIsIkVYUCI6MTYxODU1Mzg5OH0.9WTrWxCWx3YvaKZG14khp21fjkU1VjZV4e9VEf05Hok
https://www.notion.so/teamsparta/Spring-2-d27c954d8964494993c0d44823b360dc#becb243310da4491a9b222371efbed67
개발자도구 -> Preserve Log 클릭
로그인 클릭 시 Authorization 보면 JWT 생성
이는 쿠키에 토큰이라는 이름으로 저장
그 이후 요청맏다 JWT 넣어진다
<1> FormLoginFilter : 회원 폼 로그인 요청 시 username / password 인증
POST "/user/login" API 에 대해서만 동작 필요
Client 로부터 username, password 를 전달받아 인증 수행
(attempAuthentication())
인증 성공 시
-> 이제 JWT 생성되었으므로 API 요청마다 JWT를 헤더에 추가해 요청
이를 JWTAuthFilter가 인증 시도
<2> JWTAuthFilter : API 요청 Header 에 전달되는 JWT 유효성 인증
(attemptAuthentication())
- header에서 JWT 뽑아옴
null : 로그인 url로 redirect
null X : JWT가 올바른지 처리해주고 이를 담아
스프링 시큐리티에 넘겨줘서 JWT Token을 authenticate 함
-> JWTAuthProvider 호출 (JWT 유효성 검사)
1. username을 복호화하는 동시에 유효한지 체크
(decodeUsername -> isValidToken -> verify)
2. 유효기간(expiredDate) 체크
- 반환된 username 가지고 DB에서 User 검색
- Controller에서 이 인증된 사용자를 가지고 UserDetailsImpl 사용함
@Override
public boolean supports(Class<?> authentication) {
return authentication.equals(UsernamePasswordAuthenticationToken.class);
}
FormLoginAuthProvider
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
UsernamePasswordAuthenticationToken token = (UsernamePasswordAuthenticationToken) authentication;
// FormLoginFilter 에서 생성된 토큰으로부터 아이디와 비밀번호를 조회함
String username = token.getName();
String password = (String) token.getCredentials();
// UserDetailsService 를 통해 DB에서 username 으로 사용자 조회
UserDetailsImpl userDetails = (UserDetailsImpl) userDetailsService.loadUserByUsername(username);
if (!passwordEncoder.matches(password, userDetails.getPassword())) {
throw new BadCredentialsException(userDetails.getUsername() + "Invalid password");
}
return new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
}
public class FormLoginSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler {
public static final String AUTH_HEADER = "Authorization";
public static final String TOKEN_TYPE = "BEARER";
@Override
public void onAuthenticationSuccess(final HttpServletRequest request, final HttpServletResponse response,
final Authentication authentication) {
final UserDetailsImpl userDetails = ((UserDetailsImpl) authentication.getPrincipal());
// Token 생성
final String token = JwtTokenUtils.generateJwtToken(userDetails);
response.addHeader(AUTH_HEADER, TOKEN_TYPE + " " + token);
}
}
JWTAuthProvider
@Override
public Authentication authenticate(Authentication authentication)
throws AuthenticationException {
String token = (String) authentication.getPrincipal();
String username = jwtDecoder.decodeUsername(token);
// TODO: API 사용시마다 매번 User DB 조회 필요
// -> 해결을 위해서는 UserDetailsImpl 에 User 객체를 저장하지 않도록 수정
// ex) UserDetailsImpl 에 userId, username, role 만 저장
// -> JWT 에 userId, username, role 정보를 암호화/복호화하여 사용
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("Can't find " + username));;
UserDetailsImpl userDetails = new UserDetailsImpl(user);
return new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
}
// 신규 상품 등록
@PostMapping("/api/products")
public Product createProduct(@RequestBody ProductRequestDto requestDto,
@AuthenticationPrincipal UserDetailsImpl userDetails) {
// 로그인 되어 있는 ID
Long userId = userDetails.getUser().getId();
Product product = productService.createProduct(requestDto, userId);
// 응답 보내기
return product;
}
원인 : UserDetailsImpl 에서 "User" Entity 객체를 가지고 있기 때문
개선방법 :
UserDetailsImpl 에서 User 객체를 저장하지 않도록 수정
인증 사용자 정보는 Spring Security 가 제공하는 UserDetails 인터페이스 형태만 맞춰서 구현하면됨. 멤버변수는 마음대로 수정 가능
ex) UserDetailsImpl에 User 객체 대신 userId, username, role만 저장
(멤버 변수가 꼭 User 객체 가질 필요 없음)
public class UserDetailsImpl implements UserDetails {
// 삭제
private User user;
// 추가
private Long userId;
private String username;
private UserRoleEnum role;
2주차 끝나고 JWT 따로 정리하고 싶다고 했었는데 어쩌다 보니 지금 정리하게 되었다.. 처음에 들었을 때 어려웠는데 정리하면서 다시 들으니까 대략적인 흐름이 이해갔다. 나중에 이를 실제로 써보면 더 좋을 것 같다. 아즈아😬
출처 : 스파르타코딩클럽