JWT(Json Web Token)란 Json 포맷을 이용하여 사용자에 대한 속성을 저장하는 Claim 기반의 Web Token이다. JWT는 토큰 자체를 정보로 사용하는 Self-Contained 방식으로 정보를 안전하게 전달한다. 주로 회원 인증이나 정보 전달에 사용되는 JWT는 아래의 로직을 따라서 처리된다.

토큰을 저장하기 위한 방법은 일반적으로 두가지 방식이 존재한다. 첫번째는 localStorage 에 저장하는 방식이고, 두번째는 cookies에 저장하는 방식이다. 이 두가지 방식에 대해서 어떤 방식이 더 나은지에 대해 많은 사람들이 논쟁을 하지만, 대부분의 사람들은 쿠키에 저장하는 방식이 더 안전하다고 얘기한다.
XSS라고 불리는 이유는 CSS가 이미 약자가 있기 때문이고
code injection attack이라고도 한다.
XSS도 다양한 공격 방법이 있는데 우선은
공격자가 의도하는 악의적인 js 코드를 피해자 웹 브라우저에서 실행시키는 것
정도로 알고 있으면 된다.
이 방법으로 피해자 브라우저에 저장된 중요 정보들을 탈취 가능하다.
정상적인 request를 가로채 피해자인 척 하고 백엔드 서버에
변조된 request를 보내 악의적인 동작을
수행하는 공격을 의미한다. (피해자 정보 수정, 정보 열람)
ex) 내가 쓰지 않은 광고성 글이 페이스 북에 올라갈 수 있음
CSRF 공격에는 안전하다.
그 이유는 자동으로 request에 담기는 쿠키와는 다르게
js 코드에 의해 헤더에 담기므로 XSS를 뚫지 않는 이상
공격자가 정상적인 사용자인 척 request를 보내기가 어렵다.
XSS에 취약하다.
공격자가 localStorage에 접근하는 Js 코드 한 줄만 주입하면
localStorage를 공격자가 내 집처럼 드나들 수 있다.
XSS 공격으로부터 localStorage에 비해 안전하다.
쿠키의 httpOnly 옵션을 사용하면 Js에서 쿠키에 접근 자체가 불가능하다.
그래서 XSS 공격으로 쿠키 정보를 탈취할 수 없다.
(httpOnly 옵션은 서버에서 설정할 수 있음)
하지만 XSS 공격으로부터 완전히 안전한 것은 아니다.
httpOnly 옵션으로 쿠키의 내용을 볼 수 없다 해도
js로 request를 보낼 수 있으므로 자동으로 request에 실리는 쿠키의 특성 상
사용자의 컴퓨터에서 요청을 위조할 수 있기 때문.
공격자가 귀찮을 뿐이지 XSS가 뚫린다면 httpOnly cookie도 안전하진 않다.
CSRF 공격에 취약하다.
자동으로 http request에 담아서 보내기 때문에
공격자가 request url만 안다면
사용자가 관련 link를 클릭하도록 유도하여 request를 위조하기 쉽다.
코드 구현을 하면서 java, springboot를 사용하였고, 프론트로는 svelte kit과 axios를 사용하였다. 토큰 저장 방식은 localstore를 이용하였다.
// 계정 정보와 패스워드를 담음
let formData = {
username: '',
password: ''
};
// 데이터를 바디에 담아서 로그인 한 회원 정보를 보냈다.
const handleSubmit = async () => {
try {
const response = await fetch('http://localhost:8080/api/v1/member/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(formData)
});
if (response.ok) {
const data = await response.json();
const accesstoken = await data.data.accessToken;
const refreshToken = await data.data.refreshToken;
// 로그인이 성공한 경우 access토큰과 refresh토큰을 로컬스토리지에 저장시켰다.
if (data.resultCode === 'S-1') {
localStorage.setItem('accessToken', accesstoken);
localStorage.setItem('refreshToken', refreshToken);
const storedToken = localStorage.getItem('accessToken');
if (storedToken) {
// 저장된 토큰이 있다면 해당 토큰을 사용하여 원하는 작업 수행
} else {
// 저장된 토큰이 없다면, 로그인이 필요한 처리 수행
}
window.location.href = '/car-home';
} else {
// 로그인이 실패한 경우
const errorMessage = data.errorMessage;
console.error('로그인 실패:', errorMessage);
alert('아이디 또는 비밀번호가 일치하지 않습니다.');
}
} else {
console.error('서버 응답 오류:', response.statusText);
alert('다시 입력 해주세요.');
}
} catch (error) {
console.error('오류 발생:', error);
alert('존재하지 않는 계정입니다.');
}
};
axios를 사용하기 위해 프론트 쪽에 axios를 설치하였다.
Interceptor를 사용하면 request와 response를 가로채 공통적인 로직을 처리 할 수 있게 해준다.
프로젝트에서는 instance를 만들어 api 요청을 처리했는데, instance마다 401에러나, 토큰재발급을 위한 공통 로직을 처리하기 위해 interceptor를 활용하였다.
axios 참고 출처)
1. https://velog.io/@bnb8419/Axios-Interceptor%EB%A5%BC-%ED%86%B5%ED%95%B4-Refresh-Token%EC%9C%BC%EB%A1%9C-Access-Token-%EC%9E%AC%EB%B0%9C%EA%B8%89%ED%95%98%EA%B8%B
2. https://gusrb3164.github.io/web/2022/08/07/refresh-with-axios-for-client/
import axios from 'axios';
export const refreshToken = async () => {
// 해당 유저의 정보를 통해 access토큰을 새로 불러오기 위해 저장된 refresh토큰을 불러옴
const storedRefreshToken = localStorage.getItem('refreshToken');
if (!storedRefreshToken) {
console.log('로컬 스토리지에 Refresh Token이 저장되어 있지 않습니다.');
throw new Error('Refresh Token이 없어서 새로운 Access Token을 발급할 수 없습니다.');
}
// 헤더에 저장된 refresh토큰을 post로 데이터를 보냄
try {
const response = await axios.post('http://localhost:8080/api/v1/member/refresh', null, {
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${storedRefreshToken}`,
}
});
// 데이터 전송 성공하였을 시,
if (response.status === 200) {
const data = response.data;
const newAccessToken = data.data.newAccessToken;
// 새로운 Access Token으로 로컬 스토리지 업데이트
localStorage.setItem('accessToken', newAccessToken);
return newAccessToken;
} else {
console.error('서버 응답 오류:', response.statusText);
throw new Error('Access Token 갱신 중 서버 응답 오류');
}
} catch (error) {
console.error('오류 발생:', error);
throw new Error('Access Token 갱신 중 오류 발생');
}
};
공통으로 사용하는 api.js라는 파일을 하나 만들었다.
api.interceptors.request.use(
async (config) => {
// 클라이언트 쪽에만 실행시키기 위해 typeof window !== 'undefined'를 선언해줬다.
if (typeof window !== 'undefined') {
const accessToken = localStorage.getItem('accessToken');
if (!accessToken && window.location.pathname !== '/auth/login' && window.location.pathname !== '/signup-form' && window.location.pathname !== '/car-start') {
window.location.href = '/auth/login';
alert("로그인을 먼저 진행해주세요.");
return config;
} else if (accessToken && window.location.pathname === '/auth/login'){
window.location.href = '/car-home'
return config;
}
// config에 토큰값을 통해서 유저를 받아왔다.
config = await axios.post('http://localhost:8080/api/v1/verify-token', null, {
if (accessToken) {
config.headers['Content-Type'] = 'application/json';
config.headers['Authorization'] = `Bearer ${accessToken}`;
}
});
}
return config;
},
(error) => {
console.error('axios 요청 오류:', error);
return Promise.reject(error);
}
);
에러의 상태가 404이면 notFound Page
401이면 권한없음
만약 상태가 500이고, code가 7001이라면 토큰이 만료된 상태이다.
api.interceptors.response.use(
async (response) => {
if (response.status === 200) {
response = response.data;
return response;
}
if (response.status === 404) {
console.log('404 페이지로 넘어가야 함!');
}
// 응답을 받았을 때 수행할 작업
// 예: 응답 데이터 처리 등
return response;
},
async (error) => {
if (typeof window !== 'undefined') {
if (error.response && error.response.status === 401 || error.response.status === 500) {
const storedRefreshToken = await refreshToken();
if (error.config) {
error.config.headers['Content-Type'] = 'application/json';
error.config.headers['Authorization'] = `Bearer ${storedRefreshToken}`;
}
}
}
// 응답 오류 처리
return Promise.reject(error);
}
);
import axios from 'axios';
import {refreshToken} from '$lib/responesToken/responseToken.js';
const api = axios.create({
baseURL: "http://localhost:8080/api/v1/",
timeout: 1000,
});
api.interceptors.request.use(
async (config) => {
// if (typeof window !== 'undefined') {
const accessToken = localStorage.getItem('accessToken');
if (!accessToken && window.location.pathname !== '/auth/login' && window.location.pathname !== '/signup-form' && window.location.pathname !== '/car-start') {
window.location.href = '/auth/login';
alert("로그인을 먼저 진행해주세요.");
return config;
} else if (accessToken && window.location.pathname === '/auth/login'){
window.location.href = '/car-home'
return config;
}
// config = await axios.post('http://localhost:8080/api/v1/verify-token', null, {
if (accessToken) {
config.headers['Content-Type'] = 'application/json';
config.headers['Authorization'] = `Bearer ${accessToken}`;
}
// });
// }
return config;
},
(error) => {
console.error('axios 요청 오류:', error);
return Promise.reject(error);
}
);
api.interceptors.response.use(
async (response) => {
if (response.status === 200) {
response = response.data;
return response;
}
if (response.status === 404) {
console.log('404 페이지로 넘어가야 함!');
}
// 응답을 받았을 때 수행할 작업
// 예: 응답 데이터 처리 등
return response;
},
async (error) => {
if (typeof window !== 'undefined') {
if (error.response && error.response.status === 401 || error.response.status === 500) {
const storedRefreshToken = await refreshToken();
if (error.config) {
error.config.headers['Content-Type'] = 'application/json';
error.config.headers['Authorization'] = `Bearer ${storedRefreshToken}`;
}
}
}
// 응답 오류 처리
return Promise.reject(error);
}
);
export default api;
import api from '$lib/axiosEnterceptor/api.js';
if (typeof window !== 'undefined') {
const accessToken = localStorage.getItem('accessToken');
let username = api.post('/verify-token', {
// 요청 본문 데이터
}, {
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`,
}
})
.catch(function (error) {
console.log('error 오류 :',error);
});
@Component
public class JwtProvider {
private SecretKey cachedSecretKey;
@Value("${custom.jwt.secretKey}")
private String secretKeyPlain;
private SecretKey _getSecretKey() {
String keyBase64Encoded = Base64.getEncoder().encodeToString(secretKeyPlain.getBytes());
return Keys.hmacShaKeyFor(keyBase64Encoded.getBytes());
}
public SecretKey getSecretKey() {
if (cachedSecretKey == null) cachedSecretKey = _getSecretKey();
return cachedSecretKey;
}
public String getUsername(String token) {
Object usernameObject = getClaims(token).get("username");
if (usernameObject != null) {
return usernameObject.toString();
} else {
return null;
}
}
public String genToken(Map<String, Object> claims, int seconds) {
long now = new Date().getTime();
Date accessTokenExpiresIn = new Date(now + 1000L * seconds);
return Jwts.builder()
.claim("body", Util.json.toStr(claims))
.setExpiration(accessTokenExpiresIn)
.signWith(getSecretKey(), SignatureAlgorithm.HS512)
.compact();
}
public boolean verify(String token) {
try {
Jwts.parserBuilder()
.setSigningKey(getSecretKey())
.build()
.parseClaimsJws(token);
} catch (Exception e) {
return false;
}
return true;
}
public Map<String, Object> getClaims(String token) {
String body = Jwts.parserBuilder()
.setSigningKey(getSecretKey())
.build()
.parseClaimsJws(token)
.getBody()
.get("body", String.class);
return Util.toMap(body);
}
}
public class Util {
public static class json {
public static Object toStr(Map<String, Object> map) {
try {
return new ObjectMapper().writeValueAsString(map);
} catch (JsonProcessingException e) {
return null;
}
}
}
public static Map<String, Object> toMap(String jsonStr) {
try {
return new ObjectMapper().readValue(jsonStr, LinkedHashMap.class);
} catch (JsonProcessingException e) {
return null;
}
}
}
@Data
public static class LoginRequest {
@NotBlank
private String username;
@NotBlank
private String password;
}
@Getter
public static class loginresponse {
private String accessToken;
private String refreshToken;
public loginresponse(String accessToken, String refreshToken) {
this.accessToken = accessToken;
this.refreshToken = refreshToken;
}
}
@PostMapping(value = "/login", consumes = APPLICATION_JSON_VALUE)
public RsData<loginresponse> login(@Valid @RequestBody LoginRequest loginRequest, HttpServletResponse resp) {
String accessToken = memberService.genAccessToken(loginRequest.getUsername(), loginRequest.getPassword());
String refreshToken = memberService.genRefreshToken(loginRequest.getUsername(), loginRequest.getPassword());
if (accessToken == null) {
return RsData.of("Invalid username or password", null);
}
resp.addHeader("Authentication", accessToken);
resp.addHeader("Authentication", refreshToken);
return RsData.of("S-1", "토큰이 생성되었습니다.", new loginresponse(accessToken, refreshToken));
}
@AllArgsConstructor
@Getter
public class newAccessRequest {
private String newAccessToken;
}
@PostMapping(value = "/refresh", consumes = APPLICATION_JSON_VALUE)
public RsData<newAccessRequest> refresh(HttpServletRequest request, HttpServletResponse resp) {
String token = tokenController.extractTokenFromHeader(request);
String username = jwtProvider.getUsername(token);
String newAccessToken = memberService.genNewAccessToken(username);
if (newAccessToken == null) {
return RsData.of("Invalid refresh request", null);
}
// 새로운 토큰을 응답 헤더에 추가
resp.addHeader("Authentication", newAccessToken);
return RsData.of("S-1", "새로운 Access 토큰이 발급되었습니다.", new newAccessRequest(newAccessToken));
}
@AllArgsConstructor
@Getter
public static class MeResponse {
private final Member member;
}
@GetMapping(value = "/my-page", consumes = ALL_VALUE)
public RsData<MeResponse> mypage(HttpServletRequest request, HttpServletResponse resp) {
String token = tokenController.extractTokenFromHeader(request);
String username = jwtProvider.getUsername(token);
if (username == null) {
// 사용자가 인증되지 않은 경우 처리
return RsData.of("E-1", "사용자가 인증되지 않았습니다.", null);
}
Member member = memberService.findByUsername(username).orElse(null);
return RsData.of(
"S-2",
"성공",
new MeResponse(member)
);
}
public String genAccessToken(String username, String password) {
Member member = findByUsername(username).orElse(null);
if (member == null) return null;
if (!passwordEncoder.matches(password, member.getPassword())) {
return null;
}
return jwtProvider.genToken(member.toClaims(), 60 * 5);
}
public String genRefreshToken(String username, String password) {
Member member = findByUsername(username).orElse(null);
if (member == null) return null;
if (!passwordEncoder.matches(password, member.getPassword())) {
return null;
}
return jwtProvider.genToken(member.toClaims(), 60 * 60 * 24 * 365);
}
public String genNewAccessToken(String username) {
Member member = findByUsername(username).orElse(null);
if (member == null) return null;
return jwtProvider.genToken(member.toClaims(), 60 * 5);
}
출처: https://mangkyu.tistory.com/56 [MangKyu's Diary:티스토리]