JWT 를 이용하여 무분별한 API 접근을 방지하였다.
인가된 토큰을 가진 사용자들만 API 에 접근할 수 있으므로, 이를 이용하여 로그인 유지를 구현 해보자.
우선, 내가 생각한 구현 방법은 아래와 같다.
- 로그인 시 발행되는 토큰을 sessionStorage 에 저장.
- 해당 토큰에 해당하는 계정 또한 sessionStorage 에 저장.
- 페이지 로드 시 토큰과 계정을 통한 로그인
- 로그아웃 시 토큰과 계정을 sessionStorage 에서 삭제.
차례차례 구현해보자.
1. 로그인 시 발행되는 토큰을 sessionStorage 에 저장.
2. 해당 토큰에 해당하는 계정 또한 sessionStorage 에 저장.
const onClickLoginButton = async () => {
if (modalState === "login") {
axios
.post(`/api/user/login`, {
userEmail: userEmail,
userPw: userPw,
})
.then((res) => {
console.log(res.data);
const message = res.data.message;
const token = res.data.token;
axios
.get(`/api/user/detail`, {
params: {
userEmail: userEmail,
},
headers: {
Authorization: `Bearer ${token}`,
},
})
.then(async (res) => {
console.log(res.data);
const userInfo = res.data;
if (isRemember) {
await localStorage.setItem("userEmail", userEmail);
await localStorage.setItem("token", token);
await localStorage.setItem(
"userInfo",
JSON.stringify(userInfo)
);
}
await sessionStorage.setItem("userEmail", userEmail);
await sessionStorage.setItem("token", token);
await sessionStorage.setItem(
"userInfo",
JSON.stringify(userInfo)
);
setUserInfo(res.data);
setIsLogin(true);
userIconButtonRef.current.style.backgroundColor = "";
})
.catch((err) => {
console.error(err);
});
setShowModal(false);
navigate("/");
})
.catch((err) => {
console.log(err);
});
}
사용자 입력으로 받은 userEmail
과 userPw
로 우선 POST
요청을 보낸다.
정상적인 입력일 경우, 서버로 부터 메세지와 토큰을 응답 받게 된다.
userEmail
과 token
을 sessionStorage 에 저장한 뒤, 해당 계정에 대한 정보를 받아온 다음, 로그인 된 상태로 홈으로 이동한다.
localStorage 의 경우 페이지가 종료 되더라도 저장되어 있으므로, 로그인 유지를 선택한 유저들만 저장한다.
3. 페이지 로드 시 토큰과 계정을 통한 로그인
useEffect(() => {
var token = "";
var userEmail = "";
if (sessionStorage.getItem("userEmail")) {
userEmail = sessionStorage.getItem("userEmail");
token = sessionStorage.getItem("token");
} else {
userEmail = localStorage.getItem("userEmail");
token = localStorage.getItem("token");
}
if (token != null) {
axios
.get(`/api/user/detail`, {
params: {
userEmail: userEmail,
},
headers: {
Authorization: `Bearer ${token}`,
},
})
.then((res) => {
sessionStorage.setItem("userInfo", JSON.stringify(res.data));
setUserInfo(res.data);
setIsLogin(true);
})
.catch((err) => {
console.error(err);
setIsLogin(false);
});
}
}, []);
처음 페이지가 로드될 때, localStorage 에서 userEmail
과 token
을 가져오고 유효한 값일 경우 로그인 상태를 설정한다.
@GetMapping("/detail")
public ResponseEntity<User> getUserData(@RequestParam String userEmail){
User user = userService.findByEmail(userEmail);
user.setUserPw(null);
return ResponseEntity.ok(user);
}
서버에서는 userEmail
을 RequsetParam
으로 받아 user
정보를 리턴한다.
주의할 점은 user
오브젝트 자체를 넘겨버릴 경우 해당 user
의 비밀번호까지 넘겨주게 되므로, 비밀번호를 null
로 초기화 하여 넘겨준다.
4.로그아웃 시 토큰과 계정을 sessionStorage 에서 삭제.
const onClickLogoutButton = () => {
moreInfoSectionRef.current.style.display = "none";
setIsLogin(false);
localStorage.removeItem("userEmail");
localStorage.removeItem("token");
localStorage.removeItem("userInfo");
sessionStorage.removeItem("userEmail");
sessionStorage.removeItem("token");
sessionStorage.removeItem("userInfo");
console.log("logoutButton Clicked");
};
로그아웃을 클릭하면 저장된 정보들을 storage에서 제거해준다.
URL로 악의적인 접근을 통제하기 위해서, 유효한 token 을 가지고 있지 않은 경우 직접적인 URL 접근을 제한한다.
function PrivateRoute({ authenticated, component: Component }) {
return authenticated ? (
Component
) : (
<Navigate to="/" {...alert("접근할 수 없는 페이지입니다.")} />
);
}
export default PrivateRoute;
authenticated
를 state 로 관리하여, 인가된 사용자만 요청하는 컴포넌트를 화면에 표시할 수 있도록 작성한다.
사용 방법은 아래와 같다.
<Route
path="/write"
element={
<PrivateRoute
authenticated={access}
component={
<WritePage
userInfo={userInfo}
/>
}
/>
}
/>
클라이언트 단에서는 위와 같은 방법으로 접근을 제한하고, 서버 단에서는 JwtFilter
를 이용하여 접근을 제한한다.
public class WebSecurityConfig {
private final UserService userService;
@Value("${jwt.secret}")
private String secretKey;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
return httpSecurity
.httpBasic(AbstractHttpConfigurer::disable)
.csrf(AbstractHttpConfigurer::disable)
.cors(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(requests -> {
requests.requestMatchers("/api/user/login", "/api/user/register", "/api/user/valid", "/api/post/all", "/api/jwt/*").permitAll();
requests.anyRequest().authenticated();
})
.sessionManagement(
sessionManagement -> sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.addFilterBefore(new JwtFilter(userService, secretKey), UsernamePasswordAuthenticationFilter.class)
.build();
}
}
권한 없이 접근이 가능한 API 들을 제외하고, 모든 API 는 인가된 사용자만 호출할 수 있다.
@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {
private final String secretKey;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
final String authorization = request.getHeader(HttpHeaders.AUTHORIZATION);
if(authorization == null || !authorization.startsWith("Bearer ")){
logger.error("Authorization 이 없습니다.");
filterChain.doFilter(request, response);
return;
}
// Token 꺼내기
String token = authorization.split(" ")[1];
// Token 유효 검사
if(JwtUtil.isExpired(token, secretKey)){
logger.error("만료된 Token 입니다.");
filterChain.doFilter(request, response);
}
logger.info("유효 토큰 입니다.");
// UserEmail 꺼내기;
String userEmail = JwtUtil.getClaimUserEmail(token, secretKey);
// 권한 부여
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(userEmail, null, List.of(new SimpleGrantedAuthority("USER")));
// Detail 삽입
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
filterChain.doFilter(request, response);
}
}
filterChain
은 위와 같이 설정하였다.
우선 게시글 등록 API 를 개발하기 전에, post
테이블의 대한 간단한 수정이 필요했다.
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@EntityListeners(AuditingEntityListener.class)
@Getter
@Table
@Entity
public class Post {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column
private Long postId;
@ManyToOne(targetEntity = User.class)
@JoinColumn(name = "user_id")
private User user;
@Column
@CreatedDate
private LocalDateTime createdAt;
@Column
@LastModifiedDate
private LocalDateTime updatedAt;
@Column
private String title;
@Column
private String content;
@Column
private String tags;
@Builder
public Post(User user, String title, String content, String tags) {
this.user = user;
this.title = title;
this.content = content;
this.tags = tags;
}
public void update(String title, String content, String tags){
this.title = title;
this.content = content;
this.tags = tags;
}
}
tags
컬럼과 생성 시각, 수정 시각에 대한 컬럼을 새로 추가 하였다.
@RequiredArgsConstructor
@Service
public class PostService {
private final PostRepository postRepository;
private final UserRepository userRepository;
// C
public Long write(AddPostRequestDto requestDto) {
User user = userRepository.findByUserEmail(requestDto.getUserEmail()).orElseThrow(
() -> new AppException(ErrorCode.USEREMAIL_NOTEXIST, requestDto.getUserEmail() + "는 존재하지 않는 계정입니다."));
return postRepository.save(
Post.builder()
.user(user)
.title(requestDto.getTitle())
.content(requestDto.getContent())
.tags(requestDto.getTags())
.build()
).getPostId();
}
// R
public List<Post> getPostsOrderByCreatedAtDesc(){
return postRepository.findAllByOrderByCreatedAtDesc().orElseThrow(() -> new AppException(ErrorCode.POSTS_NOTEXIST, "포스트가 존재하지 않습니다."));
}
// U
// D
}
게시글을 등록하거나 불러오기 위한 서비스를 작성했다.
post
테이블의 경우 user
테이블과 1:多 관계로 맵핑 되어 있기 때문에, 게시글을 등록하기 위해선 user
정보가 필요하다.
@Getter
@Setter
public class AddPostRequestDto {
private String userEmail;
private String title;
private String content;
private String tags;
}
dto 의 구조는 간단하다.
DB에 위와 같은 형태로 저장된다.
@RequiredArgsConstructor
@RestController
@RequestMapping("/api/post")
public class PostApiController {
private final PostService postService;
// 글 작성
@PostMapping("/write")
public ResponseEntity<String> write(@RequestBody AddPostRequestDto requestDto){
Long postId = postService.write(requestDto);
return ResponseEntity.ok(String.format("게시글 번호 %d 번으로 글을 등록하였습니다.", postId));
}
// 글 불러오기
@GetMapping("/all")
public ResponseEntity<List<Post>> getPosts(@RequestParam String orderBy){
List<Post> posts = postService.getPostsOrderByCreatedAtDesc();
posts.forEach(post -> {
post.getUser().setUserPw(null);
});
return ResponseEntity.ok(posts);
}
}
추후에, 게시글을 불러올 때 최신순, 좋아요 순으로 정렬하기 위해 요청 API 는 orderBy
의 requestParam
을 받는다.
게시글이 정상적으로 서버로부터 불러와지는 모습이다.
기존에 만들었던 Jelog 는 수정할 부분이 너무나도 많다.
아,, 이래서 사람들이 코드를 수정 용이하게 작성하는 것인가 깨닳게 된다.
물론, 지금도 개발 실력이 많이 늘었다고는 할 수 없지만.. 수정해야할 부분이 너무 많이 보인다.
혼자 하려니까 힘들다 ㅠ