JWT토큰의 이동 (클라이언트 - 서버)
1. 프로젝트 마지막 날에 WebSecurityConfig를 간단하게 저장할 수 있을거라 생각하고 다른 기능 모두 완성하고서 처리하기 위해 마지막까지 미루었는데... 당장 기능 영상 찍어야 하는 상황에 시큐리티가 말썽을 부렸다... (물론 내가 해결한 것이 아니라 팀원이 애를 무쟈게 씀... ㅠ 우리 보안지킴이님..ㅠㅠ)
2. 다 제대로 작동하지가 않았지만 문제의 상황은 이것이었다.
- 열심히 HttpMethod와 함께 url을 연결지어 주었는데요
- 만약 일반 사용자가 공연정보 업로드를 하려고 post요청을 한다면 권한이 주어지지 않았기에 요청이 금지된다.
- 그런데 만약 비로그인 사용자(anonymous)가 공연정보 업로드 페이지에 접근해서 post요청을 날리면 그건 금지되지 않는다....
- 왜그런거죠!!!?
@Configuration
@RequiredArgsConstructor
public class WebSecurityConfig {
private final JwtTokenUtils jwtTokenUtils;
private final OAuth2SuccessHandler oAuth2SuccessHandler;
private final OAuth2UserServiceImpl oAuth2UserService;
@Bean
public SecurityFilterChain securityFilterChain(
HttpSecurity http
) throws Exception {
http
// csrf 보안 헤제
.csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.POST, "/alert/send/**", "/artist", "/artist/all", "/artist/{id}", "/artist/artist-check", "/shows/showInfo", "/shows/{id}/update").hasRole("ADMIN")
.requestMatchers(HttpMethod.PUT, "/artist/{id}", "/shows/{id}").hasRole("ADMIN")
.requestMatchers(HttpMethod.DELETE, "/artist/{id}", "/board/trash/{boardId}", "/comments/trash/{commentId}", "/user/{id}").hasRole("ADMIN")
.requestMatchers(HttpMethod.GET, "/user/all", "/user/{id}").hasRole("ADMIN")
// 유저만 접근 가능한 경로
.requestMatchers(HttpMethod.POST, "/artist/{id}/like", "/artist/{id}/subscribe", "/board", "/comments/{boardId}", "/comments/{boardId}/reply/{commentId}").hasRole("USER")
.requestMatchers(HttpMethod.PUT, "/board/rewriting/{boardId}", "/comments/rewriting/{commentId}").hasRole("USER")
.requestMatchers(HttpMethod.PATCH, "/user", "/user/change-password").hasRole("USER")
.requestMatchers(HttpMethod.DELETE, "/user").hasRole("USER")
.requestMatchers(HttpMethod.GET, "/user", "/user/auth/status").hasRole("USER")
// 비로그인 사용자만 접근 가능한 경로
.requestMatchers(HttpMethod.POST, "/user/login", "/user", "/user/email-check", "/user/email-auth", "/user/email-findId", "/user/loginId-check").anonymous()
// 관리자, 유저, 비로그인 사용자 모두 접근 가능한 경로
.requestMatchers(HttpMethod.GET, "/artist", "/artist/search", "/artist/{id}", "/board/{categoryId}", "/board/detail/{boardId}", "/board/title", "/board/content", "/shows", "/shows/{id}").permitAll()
.requestMatchers(HttpMethod.POST, "/user/email-send", "/user/email-pwAuth", "/user/email-tempPwSend").permitAll()
.anyRequest().authenticated()
)
// OAuth 가 어떻게 실행하는지
.oauth2Login(oauth2Login -> oauth2Login
.loginPage("/user/login")
.successHandler(oAuth2SuccessHandler)
.userInfoEndpoint(userInfo -> userInfo
.userService(oAuth2UserService))
)
// JWT 사용하기 때문에 보안 관련 세션 해제 (STATELESS: 상태를 저장하지 않음)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.addFilterBefore(
new JwtTokenFilter(jwtTokenUtils),
AuthorizationFilter.class
);
return http.build();
}
}
이때 내게 든 생각은
- 우리는 View를 다루는 일반 컨트롤러와 API를 다루는 Rest 컨트롤러를 따로 나누어 만들었기에 이때 뷰로 이동하는 url들을 모두 처리하지 않았기 때문에 그런거아닐까? 라며
- 비로그인 사용자가 해당 페이지에 접근하지 못하도록 뷰 컨트롤러도 막아야하지 않나? 라는 생각을 했다.
- 그러나, 정보보안 지킴이님은 그건 절대 아니다! 라고 했고, 결국 어찌저찌 나는 이해도 못하고 넘어갔다...ㅎㅎ
일단 이러한 문제가 발생한다고 해서 어떻게 해결을 해보고자 했으나, 나는 발표준비로 너무 바빴기 때문에 결국 해결을 못하고 넘겼다.
이제 프로젝트가 끝나고 이해를 하기 위해 다시 펼쳤다.
이해를 위해서는 전반적인 시큐리티의 진행 상황을 이해해야함을 알았다.
그림으로 정리해보았다.
과정 코드 정리 및 세부내용
1. 로그인 화면으로 이동
: 뷰로 이동
// UserViewController
@Slf4j
@Controller
public class UserViewController {
// 로그인 화면
@GetMapping("/user/login")
public String login() {
return "content/user/signIn";
}
}
2. 뷰에서 아이디 비밀번호 입력 후 submit하면 로그인 폼이 제출된다.
: 이 때 POST /user/login 으로 요청이 간다.
// SignIn.html
// ...
<!-- Main content -->
<main role="main" class="ml-sm-auto px-md-4">
<div class="container">
<div class="card">
<div class="card-body text-center">
<h2>LOGIN</h2>
<p>서비스를 사용하려면 로그인을 해주세요!</p>
<hr>
<form action="/user/login" method="POST">
<input type="hidden" th:name="${_csrf?.parameterName}" th:value="${_csrf?.token}"/>
<div class="form-group row">
<label class="col-sm-4 col-form-label text-left">아이디 :</label>
<div class="col-sm-8">
<input type="text" class="form-control" name="loginId">
</div>
</div>
<div class="form-group row">
<label class="col-sm-4 col-form-label text-left">비밀번호 :</label>
<div class="col-sm-8">
<input type="password" class="form-control" name="password">
</div>
</div>
<button type="submit" class="btn btn-dark">로그인</button>
</form>
//...
</div>
</div>
</div>
</main>
</body>
<script>
$(document).ready(function() {
$('form').submit(function(event) {
// 기본 폼 제출 이벤트 방지
event.preventDefault();
// 폼 데이터를 JSON 객체로 변환
const formData = {
loginId: $('input[name="loginId"]').val(),
password: $('input[name="password"]').val()
};
// AJAX 요청
$.ajax({
type: "POST",
url: "/user/login",
contentType: "application/json",
data: JSON.stringify(formData),
success: function(data) {
// 로그인 성공 시 로직 (예: 페이지 리다이렉트)
const token = data.token;
console.log("로그인 성공", token);
// 토큰 값이 정상적으로 반환되었는지 확인
if (token) {
// jwt 토큰을 프론트에서 저장함
localStorage.setItem("jwtToken", token);
location.href = '/';
} else {
console.log("토큰이 반환되지 않았습니다.");
// 토큰 값이 없는 경우에 대한 처리 로직을 추가할 수 있습니다.
}
},
error: function(xhr, status, error) {
// 로그인 실패 시 로직
console.log("로그인 실패", xhr.responseText);
alert("아이디나 비밀번호가 일치하지 않습니다.")
}
});
});
});
</script>
</html>
3. 로그인 요청을 보냈을 때 토큰을 발급
- 토큰 발급시 올바른 로그인 아이디인지, 비밀번호가 맞는지 확인해야 함.
- userExists 로그인 아이디 존재 확인
- passwordEncoder.matches 로그인 비밀번호 확인
- 아이디, 비밀번호가 맞다면 토큰을 생성하고, 이를 DTO에 담아서 리턴
// UserService
@Slf4j
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class UserService implements UserDetailsService {
// ...
// 로그인시 jwt 토큰을 발급하는 메서드
public JwtResponseDto issueToken(JwtRequestDto dto) {
// 로그인 아이디가 존재하는지
if (!userExists(dto.getLoginId()))
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
UserDetails userDetails = loadUserByUsername(dto.getLoginId());
// 패스워드가 같은지
if(!passwordEncoder
.matches(dto.getPassword(), userDetails.getPassword()))
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
String jwt = jwtTokenUtils.generateToken(userDetails);
JwtResponseDto response = new JwtResponseDto();
response.setToken(jwt);
return response;
}
}
4. 리턴된 DTO(토큰)를 가지고 localStorage에 저장
- data에 DTO 내부의 String token이 들어있으므로 data.token을 가지고와서,
- 제대로 반환이 되었다면 localStorage.setItem();
- 않으면 예외처리.
// SignIn.html
// AJAX 요청
$.ajax({
type: "POST",
url: "/user/login",
contentType: "application/json",
data: JSON.stringify(formData),
success: function(data) {
// 로그인 성공 시 로직 (예: 페이지 리다이렉트)
const token = data.token;
console.log("로그인 성공", token);
// 토큰 값이 정상적으로 반환되었는지 확인
if (token) {
// jwt 토큰을 프론트에서 저장함
localStorage.setItem("jwtToken", token);
location.href = '/';
} else {
console.log("토큰이 반환되지 않았습니다.");
// 토큰 값이 없는 경우에 대한 처리 로직을 추가할 수 있습니다.
}
},
error: function(xhr, status, error) {
// 로그인 실패 시 로직
console.log("로그인 실패", xhr.responseText);
alert("아이디나 비밀번호가 일치하지 않습니다.")
}
});
5. 여기까지 왔다면 로그인 후에는 localStorage에 jwtToken이라는 이름으로 토큰값이 저장이 되어있는 상태이다.
6. 그리고 어떤 서비스를 사용한다고 가정해보자. 예를 들어 내 정보 조회를 하려고 한다.
이때 정보 조회에 관한 요청은 GET /user 을 하면 된다.
중요한 것은 요청을 보내려면 header에는 Bearer Token을 첨부하고 보내야만 한다.
즉 모든 요청의 header에 Bearer Token을 첨부해서 보내야 하므로, 나는 app.js에서 Ajax 전역 설정을 통해 수동으로 매번 header에 Bearer Token을 첨부하는 것을 한번에 처리했다.
// app.js
// Ajax 전역 설정
$.ajaxSetup({
beforeSend: function(xhr) {
const jwtToken = localStorage.getItem('jwtToken');
if (localStorage.getItem('jwtToken')) {
xhr.setRequestHeader('Authorization', `Bearer ${jwtToken}`);
}
}
});
7. 헤더에 Bearer Token을 첨부하여 요청을 보내면! **필터**를 거치게 된다.
- SecurityFilterChain에서 보면 맨 아래에 .addFilterBefore() 를 통해서 해당 SecurityFilterChain 이전에 JwtTokenFilter를 추가해준다.
- 즉, 필터가 연결연결 되어있는데, 우리가 아는 필터 체인이
1) JwtTokenFilter -> 2) SecurityFilterChain 이렇게 연결된다는 뜻이다.
// WebSecurityConfig
@Configuration
@RequiredArgsConstructor
public class WebSecurityConfig {
// ...
@Bean
public SecurityFilterChain securityFilterChain(
HttpSecurity http
) throws Exception {
// ...
http
//...
.authorizeHttpRequests(auth -> auth
// 모든 뷰는 접근 가능하다.
.requestMatchers(HttpMethod.GET,"/" // 메인 뷰
// ...
.addFilterBefore(
new JwtTokenFilter(jwtTokenUtils),
AuthorizationFilter.class
);
return http.build();
}
}
8. 그러면 JwtTokenFilter에서 어떤 일이 일어나는지 확인해보자.
- Authorization 헤더 회수
- Authorization 헤더가 존재하는지, Bearer로 시작하는지 확인
- 헤더에서 token을 추출해서 유효한 토큰인지 확인
- 유효하다면 해당 토큰의 Claim부분에 들어있는 사용자 정보를 꺼낸다.
- 꺼내서 UserDetails로 받아오고
- 이를 활용해 인증 정보를 생성하고,
- 이를 그대로 SecurityContext에 저장
- 이후 다음 필터와 연결~~ (filterChain.doFilter())
// JwtTokenFilter
@Slf4j
public class JwtTokenFilter extends OncePerRequestFilter {
private final JwtTokenUtils jwtTokenUtils;
public JwtTokenFilter(
JwtTokenUtils jwtTokenUtils
) {
this.jwtTokenUtils = jwtTokenUtils;
}
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
// 1. Authorization 헤더를 회수
String authHeader
= request.getHeader(HttpHeaders.AUTHORIZATION);
// 2. Authorization 헤더가 존재하는지 + Bearer로 시작하는지
if (authHeader != null && authHeader.startsWith("Bearer ")) {
String token = authHeader.split(" ")[1];
// 3. Token이 유효한 토큰인지
if (jwtTokenUtils.validate(token)) {
// 4. 유효하다면 해당 토큰을 바탕으로 사용자 정보를 SecurityContext에 등록
SecurityContext context = SecurityContextHolder.createEmptyContext();
// 사용자 정보 회수
Claims jwtClaims = jwtTokenUtils
.parseClaims(token);
String loginId = jwtClaims.getSubject();
String authorities = jwtClaims.get("roles", String.class);
CustomUserDetails customUserDetails = CustomUserDetails.builder()
.loginId(loginId)
.authorities(authorities)
.build();
// 인증 정보 생성
AbstractAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(
customUserDetails,
token,
customUserDetails.getAuthorities()
);
// 인증 정보 등록
context.setAuthentication(authentication);
SecurityContextHolder.setContext(context);
log.info("set security context with jwt");
}
else {
log.warn("jwt validation failed");
}
}
// 5. 다음 필터 호출
// doFilter 를 호출하지 않으면 Controller 까지 요청이 도달하지 못한다.
filterChain.doFilter(request, response);
}
}
9. 그럼 다음 필터인 Authorization 필터로 가볼까욧
- 예외 핸들링 처리도 해줬고요
- csrf 보안 해제도 해주고,
- 요청에 대한 Authorize 권한 확인을 해줘서 권한에 맞지 않으면 접근 금지를 내리고요
- OAuth2 관련 설정도 해주고,
- 세션은 사용하지 않으므로 세션 해제.
- 그리고 아까 보았던 SecurityFilterChain 이전에 JwtTokenFilter를 추가해주면서 http를 빌드하면 된다.
// WebSecurityConfig
@Configuration
@RequiredArgsConstructor
public class WebSecurityConfig {
private final JwtTokenUtils jwtTokenUtils;
private final OAuth2SuccessHandler oAuth2SuccessHandler;
private final OAuth2UserServiceImpl oAuth2UserService;
@Bean
public SecurityFilterChain securityFilterChain(
HttpSecurity http
) throws Exception {
Customizer<ExceptionHandlingConfigurer<HttpSecurity>> exceptionHandlingCustomizer = exceptionHandling -> {
exceptionHandling
.accessDeniedHandler(new CustomAccessDeniedHandler())
.authenticationEntryPoint(new CustomAuthenticationEntryPoint());
};
http
.exceptionHandling(exceptionHandlingCustomizer)
// csrf 보안 헤제
.csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(auth -> auth
// 모든 뷰는 접근 가능하다.
.requestMatchers(HttpMethod.GET,"/" // 메인 뷰
,"/artists", // 아티스트 뷰
"/boards/{categoryId}", "/boards/view/{boardId}" ,"/boards/write", "/boards/rewriting/{boardId}", // 게시글 뷰
"/subscribe", // 장르 뷰
"/item/register", "/allItems", "/showItems/{showInfoId}","/item", "/success", "/fail", // 아이템 뷰
"/myOrders", // 주문 뷰
"/shows", "/shows/{id}", "/shows/showInfo", "/shows/{id}/update", // 공연 뷰
"/userInfo", "/user/login", "/user/logout", "/user/signup", "/user/update",
"/user/oauthClient", "/user/emailDuplicate", "/user/change-password", "/user/find/id", "/user/find/pw" // 유저 뷰
).permitAll()
// 어드민 권한 경로
.requestMatchers(HttpMethod.POST, "/admin/alert/send/**", "/admin/artist", "/admin/artist/all", "/artist/artist-check", "/show", "/items", "/admin/genre").hasRole("ADMIN")
.requestMatchers(HttpMethod.PUT, "/artist/{id}", "/show/{id}/update", "/items", "/genre/{id}").hasRole("ADMIN")
.requestMatchers(HttpMethod.DELETE, "/artist/{id}", "/show/{id}", "/items/{id}", "/genre/{id}").hasRole("ADMIN")
.requestMatchers(HttpMethod.GET, "/user/all", "/user/{id}", "/orders", "/orders/{id}").hasRole("ADMIN")
// 유저 권한 경로
.requestMatchers(HttpMethod.POST, "/artist/{id}/like", "/artist/{id}/subscribe", "/board", "/comments/{boardId}", "/comments/{boardId}/reply/{commentId}", "/show/{id}", "/orders/{id}/cancel", "/toss/confirm-payment", "/genre/{id}/subscribe").authenticated()
.requestMatchers(HttpMethod.PUT, "/board/rewriting/{boardId}", "/comments/rewriting/{commentId}").authenticated()
.requestMatchers(HttpMethod.DELETE, "/user", "/board/trash/{boardId}", "/comments/trash/{commentId}").authenticated()
.requestMatchers(HttpMethod.PATCH, "/user", "/user/change-password").authenticated()
.requestMatchers(HttpMethod.GET, "/orders/{id}/payment", "/user").authenticated()
.requestMatchers(HttpMethod.GET, "/myOrder").authenticated()
// 비로그인 사용자 권한 경로
.requestMatchers(HttpMethod.POST, "/user/login", "/user", "/user/email-check", "/user/email-auth", "/user/email-findId", "/user/loginId-check", "/find/loginId", "/user/signup").permitAll()
// 모든 사용자(관리자, 유저, 비로그인) 권한 경로
.requestMatchers(HttpMethod.GET, "/artist", "/artist/search", "/artist/{id}", "/board/{categoryId}", "/board/detail/{boardId}", "/board/title", "/board/content", "/show", "/show/{id}", "/show/calender", "/items", "/{showInfoId}/items", "/items/{id}", "/show/{id}/like", "/genre", "/genre/{id}").permitAll()
// 기타 모든 요청은 permitAll()
.anyRequest()
.permitAll()
)
// OAuth 가 어떻게 실행하는지
.oauth2Login(oauth2Login -> oauth2Login
.loginPage("/user/login")
.successHandler(oAuth2SuccessHandler)
.userInfoEndpoint(userInfo -> userInfo
.userService(oAuth2UserService))
)
// JWT 사용하기 때문에 보안 관련 세션 해제 (STATELESS: 상태를 저장하지 않음)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.addFilterBefore(
new JwtTokenFilter(jwtTokenUtils),
AuthorizationFilter.class
);
return http.build();
}
}
** 자, 잘 생각해보면 아까 클라이언트 측에서 !! 요청을 보낼때 뭘했을까요?
ㄴㅔ 맞슴다 Bearer을 붙여서 헤더에다가 넣어서 요청을 보냈슴다.
꼭 기억하실 것!!! Jwt 토큰 방식은 클라이언트 측에 상태를 저장하는 것으로. 클라이언트에서 꼭 header에 토큰을 넣고 요청을 보내야만 >> 필터를 통해 해당 요청의 사용자 정보 등등을 확인할 수 있다.
그리고나서 여러 필터들을 쥬르륵 거쳐서 (여기서 JwtTokenFilter와 SecurityFilterChain이 있는 것)
10. 다 잘 통과를 하면 요청이 Controller로 가게 되는 것이다.
그러면 내 정보 보기 요청으로 가보자면 이러한 서비스단을 거쳐 UserDto를 리턴하게 된다.
// UserController
@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/user")
public class UserController {
private final UserService userService;
private final AuthenticationFacade facade;
// 나의 정보 조회
@GetMapping
public UserDto myProfile(){
return userService.getMyProfile();
}
}
11. 그리고 나서 이 반한된 UserDto를 화면에 뿌려주는 역할을 하는 것이 Ajax 내부 success 부분인 것이다.
// myProfile.html
// ...
<script>
$(document).ready(function() {
$.ajax({
url: "/user",
type: "GET",
dataType: "json",
success: function(user) {
$('#profile').html(`
<img src="${user.profileImg}" alt="프로필 이미지" class="profile-img">
<p>로그인 ID: ${user.loginId}</p>
<p>이메일: ${user.email}</p>
<p>닉네임: ${user.nickname ? user.nickname : ''}</p>
<p>성별: ${user.gender ? user.gender : ''}</p>
<p>전화번호: ${user.phone ? user.phone : ''}</p>
<p>주소: ${user.address ? user.address : ''}</p>
`);
},
error: function(xhr, textStatus, error) {
if (xhr.status === 403) {
alert("권한이 없습니다.");
console.log(xhr.status);
window.history.back();
} else {
console.error("오류: " + textStatus + ": " + error);
}
}
});
$('#editProfileBtn').click(function() {
location.href = '/user/update';
});
$('#changePasswordBtn').click(function() {
location.href = '/user/change-password';
});
$('#myOrdersBtn').click(function() {
location.href = '/myOrders';
});
});
</script>
정리를 해보니 좀더 이해가 잘 되는듯한 느낌이다! 오예
무튼 결국 나의 첫 생각이었던 일반 유저가 들어갈 수 없는 뷰를 다 막아버리면 되지 않나? 라는 생각은 완전히 오류였다.
왜냐하면 우리는 클라이언트 측에서 그 사람의 권한, 토큰 등을 확인하는 것이 아닌, 뷰를 지나 ajax 요청을 할때서야 헤더에 토큰을 넣고 필터를 지나며 토큰확인을 하고 권한확인을 하며 요청이 진행이 되기 때문이라는 것!@!
그렇게 된다면 그 부분에 대해서는 뷰컨트롤러로 가는 뷰. (permitAll)이 필요하다는 것이다.
그것은 리팩토링때 고쳐보기로 한 부분이고 더욱 발전할 수 있는 부분이다~~
'Project > stage alarm project' 카테고리의 다른 글
스테이지알람 프로젝트 (1차?) 회고 (3) | 2024.04.08 |
---|---|
알람 서비스 트러블슈팅 (2) | 2024.04.04 |