vue + jpa project (16) - JWT 환경 구성 및 프론트 재수정 본문
vue + jpa project (16) - JWT 환경 구성 및 프론트 재수정
- 2023. 11. 2. 17:41
과거에 세션을 이용하여 로그인을 관리했었다면 요즘에는 JWT 토큰을 이용하여 관리하는 것이 대세이다.
HTTP는 기본적으로 state-less를 지향하기 때문에 멀티서버를 운영하는 환경인 경우에 적합하고,
토큰 값을 유지하면서 자동로그인 등을 처리하기에는 용이하기 때문이다.
1. jwt 사용을 위하여 기본값을 아래 application.yml에 등록한다.
issuer는 토큰을 구분하는 이름이 되겠고, secret-key는 토큰을 암호화하기 위한 키값에 해당한다.
키값은 본인이 원하는 대로 부여하되 너무 짧지 않아야 한다.
expiration-minutes 는 로그인에 대한 만료가 되게하는 분의 값이고, (추후 accescc_token에 대한 설정값)
refresh-expiration-hours 는 재갱신의 만료가 되는 시간을 지정한 값이다. (추후 refresh_token에 대한 설정값)
jwt:
issuer: vue-jpa-board
secret-key: 1029384756!#@$#%$^%&^*&(*)1029384756!#@$#%$^%&^*&(*)1029384756!#@$#%$^%&^*&(*)1029384756!#@$#%$^%&^*&(*)
expiration-minutes: 30
refresh-expiration-hours: 720 #24시간*30일
2. JWT 실제 처리를 위한 Provider를 아래와 같이 util 패키지에 생성한다.
// JwtProvider.java
@Slf4j
@PropertySource("classpath:application.yml")
@Component
public class JwtProvider {
private final SignatureAlgorithm signatureAlgorithm;
private final byte[] accessKeySecretBytes;
private final String issuer;
private final String secretKey;
private final long expirationMinutes;
private final long refreshExpirationHours;
private final long reissueLimit;
public JwtProvider(
@Value("${jwt.issuer}") String issuer,
@Value("${jwt.secret-key}") String secretKey,
@Value("${jwt.expiration-minutes}") long expirationMinutes,
@Value("${jwt.refresh-expiration-hours}") long refreshExpirationHours) {
this.signatureAlgorithm = SignatureAlgorithm.HS512;
this.accessKeySecretBytes = DatatypeConverter.parseBase64Binary(secretKey);
this.secretKey = secretKey;
this.expirationMinutes = expirationMinutes;
this.refreshExpirationHours = refreshExpirationHours;
this.issuer = issuer;
reissueLimit = refreshExpirationHours * 60 / expirationMinutes; // 재발급 한도
}
// 토큰 생성
public TokenDto createAccessToken(UserDto userInfo) {
Date accessExprDtm = new Date(System.currentTimeMillis() + 1 * (this.expirationMinutes * 1000 * 60));
Date refreshExprDtm = new Date(System.currentTimeMillis() + 1 * (this.refreshExpirationHours * 60 * 60 * 24));
String accessToken = Jwts.builder()
.setHeaderParam("typ", "JWT")
.setIssuer(this.issuer)
.setSubject(userInfo.getUserNo().toString())
.claim("userId", userInfo.getUserId())
.claim("userName", userInfo.getUserName())
.claim("job", userInfo.getJob())
.setIssuedAt(new Date())
.setExpiration(accessExprDtm)
.signWith(this.signatureAlgorithm, this.accessKeySecretBytes)
.compact();
//Refresh Token
String refreshToken = Jwts.builder()
.setHeaderParam("typ", "JWT")
.setIssuer(this.issuer)
.setSubject(userInfo.getUserNo().toString())
.setIssuedAt(new Date())
.setExpiration(refreshExprDtm)
.signWith(this.signatureAlgorithm, this.accessKeySecretBytes)
.compact();
log.debug("########### Access token 만료일자 :: {}, Refresh Token 토큰 만료일자:: {}", accessExprDtm, refreshToken);
return TokenDto.builder().accessToken(accessToken).refreshToken(refreshToken).userNo(userInfo.getUserNo()).exprDate(accessExprDtm).build();
}
// access token 재생성
public String recreationAccessToken(UserDto userInfo){
Date accessExprDtm = new Date(System.currentTimeMillis() + 1 * (this.expirationMinutes * 1000 * 60));
String accessToken = Jwts.builder()
.setHeaderParam("typ", "JWT")
.setIssuer(this.issuer)
.setSubject(userInfo.getUserNo().toString())
.claim("userId", userInfo.getUserId())
.claim("userName", userInfo.getUserName())
.claim("job", userInfo.getJob())
.setIssuedAt(new Date())
.setExpiration(accessExprDtm)
.signWith(this.signatureAlgorithm, this.accessKeySecretBytes)
.compact();
log.debug("########### Access token 다시 재생성한 :: {}, 토큰 만료일자:: {}", accessToken, accessExprDtm);
return accessToken;
}
public String validateRefreshToken(String accessToken, String refreshToken){
try {
// 검증
Jws<Claims> accessClaims = Jwts.parser().setSigningKey(this.accessKeySecretBytes).parseClaimsJws(accessToken);
Jws<Claims> refreshClaims = Jwts.parser().setSigningKey(this.accessKeySecretBytes).parseClaimsJws(refreshToken);
//refresh 토큰의 만료시간이 지나지 않았을 경우, 새로운 access 토큰을 생성합니다.
if (!refreshClaims.getBody().getExpiration().before(new Date())) {
UserDto userInfo = new UserDto();
userInfo.setUserNo(Long.parseLong(getUserNo(accessToken)));
userInfo.setUserId(getUserId(accessToken));
userInfo.setUserName(getUserName(accessToken));
userInfo.setJob(getJob(accessToken));
return recreationAccessToken(userInfo);
}
}catch (Exception e) {
log.error("토큰 정보가 유효하지 않습니다.");
log.error(e.getMessage());
return null;
}
return null;
}
public Jws<Claims> decodeToken(String accessToken) {
try {
Jws<Claims> accessClaims = Jwts.parser().setSigningKey(this.accessKeySecretBytes).parseClaimsJws(accessToken);
return accessClaims;
} catch (IllegalArgumentException e) {
log.error("JWT claims string is empty: {}", e.getMessage());
} catch (Exception e) {
log.error("JWT claims error : {}", e.getMessage());
}
return null;
}
public String getUserNo(String accessToken) throws RuntimeException{
try {
Jws<Claims> tokenInfo = decodeToken(accessToken);
return tokenInfo.getBody().getSubject();
} catch (Exception e) {
return null;
}
}
public String getUserId(String accessToken) throws RuntimeException{
try {
Jws<Claims> tokenInfo = decodeToken(accessToken);
return (String)tokenInfo.getBody().get("userId");
} catch (Exception e) {
return null;
}
}
public String getUserName(String accessToken) throws RuntimeException{
try {
Jws<Claims> tokenInfo = decodeToken(accessToken);
return (String)tokenInfo.getBody().get("userName");
} catch (Exception e) {
return null;
}
}
public String getJob(String accessToken) throws RuntimeException{
try {
Jws<Claims> tokenInfo = decodeToken(accessToken);
return (String)tokenInfo.getBody().get("job");
} catch (Exception e) {
return null;
}
}
public Date getExpDate(String accessToken) {
try {
Jws<Claims> tokenInfo = decodeToken(accessToken);
return tokenInfo.getBody().getExpiration();
} catch (Exception e) {
return null;
}
}
}
여기서 JWT의 subject가 키값에 해당하는데 나의 경우에는 userNo를 기준으로 하였다.
보통 access token에 사용자 정보 등을 저장시켜서 추후에 꺼내어 볼 수 있도록 하는 경우가 많다.
그리고 refresh token의 경우에는 단순히 expire date 를 확인하는 정도여서 정보를 저장할 필요가 없다.
추가적으로 getUserId, getUserName, getUserJob 등의 메소드를 만들어서 향후 값을 불러올 수 있도록 하였다.
잘되는지 테스트를 통해서 테스트를 해보겠다.
... 중략
@Autowired
JwtProvider jwtProvider;
... 중략
@DisplayName("토큰정보 확인")
@Test
void tokenTest(){
UserDto userInfo = new UserDto();
userInfo.setUserNo(1L);
userInfo.setUserId("user1");
userInfo.setUserName("사용자1");
userInfo.setJob("j01");
TokenDto token = jwtProvider.createAccessToken(userInfo);
System.out.println("Token : " + token.toString());
assertThat(jwtProvider.getUserName(token.getAccessToken()).toString()).isEqualTo("사용자1");
}
정상적으로 잘 동작을 하였다.
3. TokenRequestFilter 를 filter 패키지아래 생성하여 Http 요청이 있을 때마다 처리가 되도록 할 것이다.
그러기 위해서 OncePerRequestFilter 를 확장하고 메소드를 오버라이드 시켜서 구현한다.
// TokenRequestFilter.java
@Slf4j
@RequiredArgsConstructor
@Component
public class TokenRequestFilter extends OncePerRequestFilter {
private final JwtProvider jwtProvider;
private final TokenService tokenService;
private final UserService userService;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
try {
if("/user/login".contains(request.getRequestURI())) {
doFilter(request, response, filterChain);
} else {
String access_token = parseAccessJWT(request);
String refresh_token = parseRefreshJWT(request);
if(access_token == null || refresh_token == null) {
response.sendError(403);
} else {
Jws<Claims> tokenInfo = jwtProvider.decodeToken(access_token);
if (tokenInfo != null) {
String userNo = tokenInfo.getBody().getSubject();
String userId = (String) tokenInfo.getBody().get("userId");
log.debug("userNo : " + userNo + " // userid : " + userId);
UserDetails loginUser = userService.loadUserByUsername(userId);
UserDto userInfo = userService.selectUserInfo(userId);
UsernamePasswordAuthenticationToken authenticationToken
= new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
// refresh token 검증. DB에 있는 것과 동일한지 검증
TokenDto tokenDto = tokenService.retrieveUserJWTTokenInfo(userInfo.getUserNo());
if(!refresh_token.equals(tokenDto.getRefreshToken()) ) {
response.sendError(403);
return;
}
// 검증 후, refresh token이 만료가 안되었다면 새로운 access 토큰을 생성합니다.
String reAccessToken = jwtProvider.validateRefreshToken(access_token, refresh_token);
if(reAccessToken != null) {
response.setHeader("access_token", reAccessToken);
response.setHeader("test_string", "hahahahaha"); // 테스트용임
} else {
log.error("### Refresh Token is not Valid");
response.sendError(403);
}
doFilter(request, response, filterChain);
} else {
log.error("### TokenInfo is Null");
response.sendError(403);
}
}
}
} catch(Exception e) {
log.error("### Filter Exception {}", e.getMessage());
response.sendError(403);
}
}
private String parseAccessJWT(HttpServletRequest request) {
String headerAuth = request.getHeader("Authorization");
if(StringUtils.hasText(headerAuth) && headerAuth.startsWith("Bearer ")) {
return headerAuth.substring(7);
}
return null;
}
private String parseRefreshJWT(HttpServletRequest request) {
String headerAuth = request.getHeader("refresh_token");
if(StringUtils.hasText(headerAuth) && headerAuth.startsWith("Bearer ")) {
return headerAuth.substring(7);
}
return null;
}
}
4. 위의 필터를 기존의 WebSecurityConfig 쪽에서 filter를 걸어서 처리가 되게끔 포함시킨다.
// WebSecurityConfig.java
@RequiredArgsConstructor
@EnableWebSecurity
@Configuration
public class WebSecurityConfig {
private final TokenRequestFilter tokenRequestFilter; // 추가
...중략
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.cors()
.and()
.csrf().disable().authorizeRequests()
.anyRequest().permitAll()
.and()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.formLogin().disable()
// 아래줄 추가됨
.addFilterBefore(tokenRequestFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
5. 사용자 컨트롤러를 아래와 같이 생성한다.
로그인시에 검증하는 절차를 거쳐서 토큰이 발급되고 그것을 프론트로 리턴해서 전달해주는 역할을 한다.
// UserController.java
@Slf4j
@RequiredArgsConstructor
@CrossOrigin
@RestController
@RequestMapping("/user")
public class UserController {
private final JwtProvider jwtProvider;
private final TokenService tokenService;
private final UserService userService;
private final AuthenticationManager authenticationManager;
@PostMapping("/login")
public ResponseEntity<Map<String, Object>> login(@RequestBody Map<String, String> paramMap, HttpServletRequest request)
{
String userId = paramMap.get("user_id");
String userpw = paramMap.get("user_pw");
UserDetails loginUser = userService.loadUserByUsername(userId); //userId로 정보 가져오기
//가져온 정보와 입력한 비밀번호로 검증
Authentication authentication = authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(userId, userpw));
SecurityContextHolder.getContext().setAuthentication(authentication); // 검증 통과 후 authentication 세팅
UserDto userInfo = userService.selectUserInfo(userId);
// accessToken, refreshtoken 생성
TokenDto tokenDto = jwtProvider.createAccessToken(userInfo);
tokenDto.setIp(request.getRemoteAddr());
// 토큰 DB 저장
try {
tokenService.create(tokenDto);
} catch (Exception e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
String accessToken = tokenDto.getAccessToken();
String refresh_token = tokenDto.getRefreshToken();
Map<String, Object> result = new HashMap<>();
result.put("user_id", loginUser.getUsername());
result.put("access_token", accessToken);
result.put("refresh_token", refresh_token);
result.put("user_role", loginUser.getAuthorities().stream().findFirst().get().getAuthority());
return ResponseEntity.ok(result);
}
6. 프론트와 백엔드 간의 CORS, Header 처리 등을 위해서 WebMvcConfig.java 파일에 아래와 같이 추가가 필요하다.
// WebMvcConfig.java
... 중략
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOrigins("http://localhost:8080", "http://localhost:8081")
.allowedMethods("*")
.allowedHeaders("Authorization", "Content-Type", "Accept", "Origin", "refresh_token")
// Authorization 헤더에서 access_token을
// refresh_token 헤더에서 refresh_token 을 전달 받을 수 있도록 허용한다.
.exposedHeaders("access_token", "test_string")
// client에서 header추출이 가능하도록 하기 위해 등록
// test_string 는 테스트를 위한 것이어서 생략 가능
.allowCredentials(true)
.maxAge(3600);
}
...중략
7. 프론트에서 LoginAPI.js 파일을 실제 백엔드 프로세스를 호출하도록 변경한다.
// loginAPI.js
import axios from "axios"
const getUserInfo = (userId, userPw)=> {
const reqData = {
'user_id': userId,
'user_pw': userPw
}
let serverUrl = '//localhost:8081'
return axios.post(serverUrl + '/user/login', reqData, {
headers: {
'Content-type': 'application/json'
}
})
/* 이 부분은 삭제
return {
'data' : {
'user_id' : reqData.user_id,
'user_token' : 'user_test_token',
'user_role' : 'ADM',
}
}
*/
}
...중략
8. 프론트에서 util 폴더를 생성하고 그 밑에 axios.js 파일을 새로 추가한다.
// axios.js
import axios from "axios"
import store from "@/vuex/store.js"
import router from "@/router/router"
axios.requireAuth = () => (from, to, next) => {
const access_token = localStorage.getItem('access_token')
const refresh_token = localStorage.getItem('refresh_token')
if (access_token && refresh_token) {
store.state.isLogin = true
return next()
} // isLogin === true면 페이지 이동
store.state.isLogin = false
next('/login') // isLogin === false면 다시 로그인 화면으로 이동
}
axios.interceptors.request.use(function(config) {
//store.commit('LOADING_STATUS', true) --> 해당 줄은 이후 loding bar 관련 시 주석을 해제한다.
const access_token = localStorage.getItem('access_token')
const refresh_token = localStorage.getItem('refresh_token')
config.headers.Authorization = "Bearer " + access_token
config.headers.refresh_token = "Bearer " + refresh_token
return config
})
axios.interceptors.response.use(function(config) {
//store.commit('LOADING_STATUS', false) --> 해당 줄은 이후 loding bar 관련 시 주석을 해제한다.
// 새로 발급받은 액세스 토큰을 저장함
localStorage.setItem('access_token', config.headers.access_token)
return config
}, function (error) {
// 응답이 에러인 경우에 미리 전처리할 수 있다.
return Promise.reject(error);
})
export default axios
9. 기존 main.js에 포함 시켰던 기본 axios를 새로 추가한 axios.js로 대체한다.
// main.js
... 중략
import { createApp } from 'vue'
import App from './App.vue'
import router from './router/router.js'
import axios from './util/axios.js' // router 아래에 위치 해야함
import store from './vuex/store.js'
...중략
10. 기존 Loing.vue 파일에서 처리되는 부분을 아래와 같이 수정한다.
로그인 성공 이후 메세지와 페이지 이동을 추가하였다.
// Login.vue
... 중략
methods: {
...mapActions(['login']), //vuex/actions에 있는 login 함수
async fnLogin() { //async 함수로 변경
if (this.user_id === '') {
alert('ID를 입력하세요.')
return
}
if (this.user_pw === '') {
alert('비밀번호를 입력하세요.')
return
}
//로그인 API 호출
try {
let loginResult = await this.login({user_id: this.user_id, user_pw: this.user_pw})
if (loginResult) {alert('로그인에 성공하였습니다.'); this.goToPages()}
} catch(err) {
if (err.message.indexOf('Network Error') > -1) {
alert('서버에 접속할 수 없습니다. 상태를 확인해주세요.')
} else {
alert('로그인 정보를 확인할 수 없습니다.')
}
}
},
goToPages() {
this.$router.push({
name: 'BoardList'
})
}
...중략
이를 통해서 최초 Login.vue 에서 async fnLogin() 함수를 호출하면 actions.js 에 정의한 login 함수를 호출하게되고,
그 내부에서 loginAPI.js의 async doLogin(userId, userPw) 를 호출하게 되고, 바로 getUserInfo(userId, userPw) 호출을
하여 axios.post(serverUrl + '/user/login') 부분을 호출하여 백엔드를 처리하게 된다. 그 뒤로 /util/axios.js 에 정의되어 있는
interceptor를 통해서 request 요청일때와 response 응답을 받을 때마다 처리를 수행한다.
내가 봐도 누가 봐도 좀 복잡하다. ^^;;;
이제 모든 처리는 다 되었으니 잘 동작하는지 확인해보자
로그인 이후에 게시판으로 자동 이동이 되고 페이지가 이동 되더라도 일단 잘된다.
물론 게시판 이동시에 체크로직이 없어서 잘 되는 것이겠지만 일단 이 정도라도 잘 된다면 성공한 것이다.
다음 장에서는 게시판 이동 전에 사전 체크하여 처리가 되도록 하고, 토큰이 만료가 되면 다시 로그인 페이지로
이동하도록 하겠다.
'프로그램 > Vue.js' 카테고리의 다른 글
vue + jpa project (18) - 로딩바 처리 (0) | 2023.11.07 |
---|---|
vue + jpa project (17) - 화면이동 사전체크 및 로그아웃 처리 (0) | 2023.11.07 |
vue + jpa project (15) - 로그인 및 백엔드 환경 초기 구성 (0) | 2023.11.01 |
vue + jpa project (14) - vuex설정 및 로그인 화면 생성 (0) | 2023.10.31 |
vue + jpa project (13) - 게시판 첨부파일 처리(이미지) (0) | 2023.10.31 |
RECENT COMMENT