2021. 6. 27. 16:03ㆍSpring Security/Spring Boot Jwt with JPA and Redis
사용자 로그인에 대한 로직을 살펴보도록 하겠습니다.
아래 시퀀스 다이어그램을 통해서 전체적인 로직을 확인 해보도록 하겠습니다.
JwtFilter
로그인시 발급된 토큰 및 쿠키 정보가 없기 때문에 로직 수행없이 LoginController 의 POST /signin 을 수행하게 됩니다.
LoginController.signin
package com.roopy.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.roopy.crypto.AES256Cipher;
import com.roopy.jwt.JwtFilter;
import com.roopy.security.jwt.payload.request.LoginRequest;
import com.roopy.security.jwt.payload.response.TokenResponse;
import com.roopy.util.CookieUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.*;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.RestTemplate;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.validation.Valid;
import java.util.HashMap;
@RestController
public class LoginController {
private static final Logger logger = LoggerFactory.getLogger(LoginController.class);
@Autowired
private RestTemplate restTemplate;
@Autowired
private CookieUtil cookieUtil;
@Value("${jwt.user-cookie-name}")
private String userCookieName;
@Value("${jwt.access-token-cookie-name}")
private String accessTokenCookieName;
@Value("${jwt.refresh-token-cookie-name}")
private String refreshTokenCookieName;
/**
* 로그인
*
* <p>
* 1.인증서버에 AccessToken, RefreshToken 발급요청
* 2.발급된 토큰 정보에 대한 쿠키 생성
* </p>
*
*
* @param httpServletRequest HttpServletRequest
* @param httpServletResponse HttpServletResponse
* @param loginRequest set username and password
* @return TokenResponse accessToken
* @throws Exception
*/
@PostMapping("/signin")
public ResponseEntity<TokenResponse> signin(HttpServletRequest httpServletRequest
, HttpServletResponse httpServletResponse
, @Valid @RequestBody LoginRequest loginRequest) throws Exception {
CookieUtil cookieUtil = new CookieUtil();
// 토큰 발행 요청
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
ObjectMapper mapper = new ObjectMapper();
HttpEntity<String> request = new HttpEntity<>(mapper.writeValueAsString(loginRequest), headers);
ResponseEntity<HashMap> responseEntity = restTemplate.postForEntity( "http://localhost:10001/token/issue", request , HashMap.class );
// 토큰 발행 요청 결과
String accessToken = null;
String refreshToken = null;
Cookie userCookie = null;
Cookie accessTokenCookie = null;
Cookie refreshTokenCookie = null;
if (responseEntity.getStatusCodeValue() == HttpStatus.OK.value()) {
accessToken = (String) responseEntity.getBody().get("accessToken");
refreshToken = (String) responseEntity.getBody().get("refreshToken");
// User 쿠키 설정
// Token 쿠키가 다 삭제되는 경우 h2db, Redis 서버에 존재하는 토큰 정보
// 를 삭제 처리를 위해서
// username 을 설정한다.
// 단 토큰 삭제 처리 후 User 쿠키도 삭제 처리 해야 한다.
userCookie = cookieUtil.createCookie(userCookieName, AES256Cipher.encrypt(loginRequest.getUsername()), "");
// AccessToken 쿠키 저장
accessTokenCookie = cookieUtil.createCookie(accessTokenCookieName, accessToken, "A");
// RefreshToken 쿠키 저장
refreshTokenCookie = cookieUtil.createCookie(refreshTokenCookieName, AES256Cipher.encrypt(refreshToken), "R");
httpServletResponse.addCookie(userCookie);
httpServletResponse.addCookie(accessTokenCookie);
httpServletResponse.addCookie(refreshTokenCookie);
logger.debug("=================================================================================");
logger.debug("Log in : AccessToken({}), RefreshToken({}) 발급 완료", accessToken, refreshToken);
logger.debug("=================================================================================");
}
return new ResponseEntity<>(new TokenResponse(accessToken), HttpStatus.OK);
}
/**
* 로그아웃
*
* @param httpServletRequest HttpServletRequest
* @param httpServletResponse HttpServletResponse
* @return
* @throws Exception
*/
@PostMapping("/signout")
public ResponseEntity<String> signout(HttpServletRequest httpServletRequest
, HttpServletResponse httpServletResponse) throws Exception {
// 사용자 쿠키 삭제
Cookie userCookie = cookieUtil.expireCookie(userCookieName);
// AccessToken 쿠키 삭제
Cookie accessTokenCookie = cookieUtil.expireCookie(accessTokenCookieName);
// RefreshToken 쿠키 삭제
Cookie refreshTokenCookie = cookieUtil.expireCookie(refreshTokenCookieName);
httpServletResponse.addCookie(userCookie);
httpServletResponse.addCookie(accessTokenCookie);
httpServletResponse.addCookie(refreshTokenCookie);
return new ResponseEntity<>("로그 아웃 처리 되었습니다.", HttpStatus.OK);
}
}
소스에서 토큰 발행 요청은 로그인시 입력받은 사용자ID와 비밀번호를 통하여서 auth-server에 토큰 생성을 요청한다.
auth-server 에서는 생성된 accessToken과 refreshToken 정보를 반환하게 된다.
TokenController.issueToken
/**
* 토큰 관리를 위한 클래스
*/
@RestController
@RequestMapping("/token")
public class TokenController {
@Autowired
private AuthService authService;
/**
* 토큰 발급
*
* @param loginRequest 토큰 발급을 위한 ID, 비밀번호
* @return ResponseEntity<Map<String,Object>> AccessToken, RefreshToken
*/
@PostMapping("/issue")
public ResponseEntity<Map<String,Object>> issueToken(@Valid @RequestBody LoginRequest loginRequest) throws Exception {
TokenResponse tokenResponse = authService.generateAccessTokenAndRefreshToken(loginRequest);
Map<String,Object> retObj = new HashMap<>();
retObj.put("accessToken", tokenResponse.getAccessToken());
retObj.put("refreshToken", tokenResponse.getRefreshToken());
return new ResponseEntity<>(retObj, HttpStatus.OK);
}
}
토큰 발급을 위해서 LoginController 에서 전달받은 파라미터(사용자ID,비밀번호)로 AccessToken과 RefreshToken을 생성을 위해 AuthService.generateAccessTokenAndRefresh 메소드를 호출 한다.
AuthService.generateAccessTokenAndRefreshToken
package com.roopy.service.impl;
import com.roopy.crypto.AES256Cipher;
import com.roopy.entity.RefreshToken;
import com.roopy.entity.User;
import com.roopy.exception.TokenNotFoundException;
import com.roopy.repository.RefreshTokenRepository;
import com.roopy.repository.UserRepository;
import com.roopy.security.jwt.TokenProvider;
import com.roopy.security.jwt.payload.request.LoginRequest;
import com.roopy.security.jwt.payload.response.TokenResponse;
import com.roopy.service.AuthService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.Date;
import java.util.Optional;
import java.util.UUID;
@Service
public class AuthServiceImpl implements AuthService {
private final Logger logger = LoggerFactory.getLogger(AuthService.class);
@Autowired
private AuthenticationManagerBuilder authenticationManagerBuilder;
@Autowired
private TokenProvider tokenProvider;
@Autowired
private RefreshTokenRepository refreshTokenRepository;
@Autowired
private UserRepository userRepository;
@Autowired
private CustomUserDetailsServiceImpl userDetailsService;
@Override
@Transactional
public TokenResponse generateAccessTokenAndRefreshToken(LoginRequest loginRequest) throws Exception {
// 1. accessToken 생성
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginRequest.getUsername(), loginRequest.getPassword());
Authentication authentication = null;
try {
authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
} catch (BadCredentialsException e) {
throw new BadCredentialsException("[" + loginRequest.getUsername() + "] 사용자 정보가 존재 하지 않습니다.");
}
String accessToken = tokenProvider.createToken(authentication);
logger.debug("AccessToken({}) 이 정상적으로 발급 되었습니다.", accessToken);
// 2. refreshToken 생성
Calendar c = Calendar.getInstance();
// 만료시간 5분 설정
c.add(Calendar.MINUTE, 5);
RefreshToken refreshTokenRequest = RefreshToken.builder()
.id(UUID.randomUUID().toString())
.username(loginRequest.getUsername())
.password(AES256Cipher.encrypt(loginRequest.getPassword()))
.expiryDate(c.getTime())
.build();
RefreshToken refreshTokenResponse = refreshTokenRepository.save(refreshTokenRequest);
logger.debug("RefreshToken({}) 이 정상적으로 발급 되었습니다.", refreshTokenResponse.getId());
// RefreshToken 재발급후 AccessToken 재발급시 Encoding 되지 않은 사용자의 비밀번호가 필요 한데
// Encoding 전의 비밀번호는 Redis 서버에에 저장 하고 있는데 RefreshToken 만료시 쿠키 정보가 삭제
// 되므로 사용자의 비밀번호를 알 수 없으므로 사용자 테이블에 RefreshToken 발급전 토큰 정보를 업데이트 처리한다.
Optional<User> user = userRepository.findOneWithAuthoritiesByUsername(loginRequest.getUsername());
user.get().setToken(AES256Cipher.encrypt(refreshTokenResponse.getId()));
userRepository.save(user.get());
// 3. return 객체 생성
TokenResponse tokenResponse = TokenResponse.builder()
.accessToken(accessToken)
.refreshToken(refreshTokenResponse.getId())
.authentication(authentication)
.build();
return tokenResponse;
}
}
AccessToken 생성을 위해서 사용자ID, 비밀번호로 Authentication 객체 생성 후 TokenProvider.crateToken(authentication) 을 호출 하여서 AccessToken을 생성한다.
TokenProvider.createToken
package com.roopy.security.jwt;
import io.jsonwebtoken.*;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.stereotype.Component;
import java.security.Key;
import java.util.*;
import java.util.stream.Collectors;
/**
* 토큰생성, 토큰정보조회, 유효성체크를 위한 클래스
*/
@Component
public class TokenProvider implements InitializingBean {
private final Logger logger = LoggerFactory.getLogger(TokenProvider.class);
private static final String AUTHORITIES_KEY = "auth";
private final String secret;
private Key key;
/**+
* 기본생성자
*
* @param secret set secret key from application.yml in jwt.secret
*/
public TokenProvider(
@Value("${jwt.secret}") String secret) {
this.secret = secret;
}
/**+
* 토큰 생성
*
* @param authentication set authentication object
* @return token
*/
public String createToken(Authentication authentication) {
// 사용자의 권한 정보를 구분자를 콤마로 하여서 authorities 변수에 할당
String authorities = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));
Calendar c = Calendar.getInstance();
// AccessToken 만료시간 30분 설정
c.add(Calendar.MINUTE, 2);
Date validity = c.getTime();
return Jwts.builder()
.setSubject(authentication.getName())
.claim(AUTHORITIES_KEY, authorities)
.signWith(key, SignatureAlgorithm.HS512)
.setExpiration(validity)
.compact();
}
@Override
public void afterPropertiesSet() throws Exception {
// 생성자에 주입받은 secret key decoding 처리
byte[] keyBytes = Decoders.BASE64.decode(secret);
// decoding secret key key 변수에 할당
this.key = Keys.hmacShaKeyFor(keyBytes);
}
}
RefreshToken 생성을 위해서 파라미터 생성 후 Redis 서버에 정보를 저장한다.
'Spring Security > Spring Boot Jwt with JPA and Redis' 카테고리의 다른 글
AccessToken 재발급 테스트 (0) | 2021.07.02 |
---|---|
AccessToken 재발급 프로세스 (0) | 2021.06.28 |
로그인 테스트 (0) | 2021.06.27 |
프로젝트 소개 및 설정 (0) | 2021.06.27 |
Token Life Cycle And Cookie (0) | 2021.06.27 |