API 문서 작성
이번엔 예외 처리를 포함해 API 문서를 작성하였다.
전체 스크린 샷 대신 링크를 넣었다.
입력 유효성 검사, jwt 인증 예외 처리에 대한 건 따로 넣지 않았다.
1차 개발 목표
- 마감일: 11/22(금)
- 개발 기간: 약 2주
- 개발 목표: 로컬에서 실행되는 백엔드 애플리케이션
프론트엔드 개발은 2차 개발에 들어갈 예정이다.
배포는 아직 생각하지 않았다.
계획
노션 페이지의 보드, 타임라인을 활용하였다.
하루 3시간 정도 개발에 투자한다고 가정했다.
주말은 조금 여유롭게 일정을 잡았다.
jwt 인증
아직 회원가입, 로그인 API가 구현이 되지 않아 테스트를 하긴 힘들지만 인증 과정~ 예외 처리까지 구현 해보았다.
이 말은 즉 에러가 있을 수 있으니 그대로 참고하기 좋지 않다는 뜻이다!
JwtProvider
package com.et.eachtogether.security;
import com.et.eachtogether.user.User;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import jakarta.annotation.PostConstruct;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.security.Key;
import java.util.Base64;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
@Component
public class JwtProvider {
@Value("${jwt.secret}")
private String SECRET_KEY;
@Value("${jwt.expiration.access}")
private Long EXPIRATION_ACCESS;
public final String HEADER = "Authorization";
private final String TOKEN_PREFIX = "Bearer ";
public static final String CLAIM_NICKNAME = "nickname";
public static final String CLAIM_ROLE ="role";
private Key key;
@PostConstruct
public void init() {
byte[] bytes = Base64.getDecoder().decode(SECRET_KEY);
key = Keys.hmacShaKeyFor(bytes);
}
public String generateToken(User user) {
Date curDate = new Date();
Date expireDate = new Date(curDate.getTime() + EXPIRATION_ACCESS);
return TOKEN_PREFIX + Jwts.builder()
.setSubject(user.getEmail())
.setClaims(createClaims(user))
.setExpiration(expireDate)
.setIssuedAt(curDate)
.signWith(key, SignatureAlgorithm.HS256)
.compact();
}
private Map<String, Object> createClaims(User user) {
Map<String, Object> claims = new HashMap<>();
claims.put(CLAIM_NICKNAME, user.getNickname());
// to do: 이후 관리자/유저 유연하게 넣을 수 있도록 변경 예정
claims.put(CLAIM_ROLE, "ROLE_USER");
return claims;
}
public String getToken(HttpServletRequest request) {
String token = request.getHeader(HEADER);
if(StringUtils.hasText(token) && token.startsWith(TOKEN_PREFIX)) {
return token.replace(TOKEN_PREFIX, "");
}
return null;
}
public Claims getClaims(String token) {
return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody();
}
// to do: 직접 String을 작성하지 않는 방법은?
public boolean isValidToken(String token, HttpServletRequest request) {
try {
getClaims(token);
return true;
} catch (SecurityException | MalformedJwtException | io.jsonwebtoken.security.SignatureException e) {
request.setAttribute("error", "Invalid JWT signature, 유효하지 않는 JWT 서명 입니다.");
} catch (ExpiredJwtException e) {
request.setAttribute("error", "Expired JWT token, 만료된 JWT token 입니다.");
} catch (UnsupportedJwtException e) {
request.setAttribute("error", "Unsupported JWT token, 지원되지 않는 JWT 토큰 입니다.");
} catch (IllegalArgumentException e) {
request.setAttribute("error", "JWT claims is empty, 잘못된 JWT 토큰 입니다.");
}
return false;
}
}
사실 지난 최종 팀프로젝트에서 썼던 jwtProvider와 거의 유사하다. (내가 담당했었기 때문)
nickname, email, role정도가 프론트엔드에서 자주 사용할 것 같아서 넣어주었다.
role은 일단 무조건 user로 들어가게 했는데, 나중에 백오피스 기능을 만들게 되면 수정할 예정이다.
그리고 token이 유효한지 확인하는 isValidToken 메서드를 눈여겨 볼 필요가 있다.
catch되었을 경우 request에 error라는 어트리뷰트로 어떤 에러가 발생하였는지 문구를 넣어주었다.
여기서 직접 String이 써져있는 것이 썩 좋아 보이진 않는데 고민이 필요해 보인다.
JwtAuthenticationFilter
package com.et.eachtogether.security;
import io.jsonwebtoken.Claims;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtProvider jwtProvider;
private final UserDetailsServiceImpl userDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
String token = jwtProvider.getToken(request);
if(validateToken(token, request)) {
Claims claims = jwtProvider.getClaims(token);
setAuthentication(claims.getSubject());
}
filterChain.doFilter(request, response);
}
private boolean validateToken(String token, HttpServletRequest request) {
if(token == null || token.isEmpty())
return false;
return jwtProvider.isValidToken(token, request);
}
private void setAuthentication(String username) {
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
Authentication authentication =
new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
인증을 처리하는 필터이다.
토큰의 유효성을 확인하는 부분,
유효한 토큰일 경우 Authentication(인증된 정보)를 SecurityContext에 넣어주는 부분으로 구성된다.
인증 아키텍처 참고
https://docs.spring.io/spring-security/reference/servlet/authentication/architecture.html
이 필터는 모든 요청이 스프링 컨텍스트에 들어가기 전 거친다.
즉 인증이 필요 없는 경우에도 (토큰이 없어도 되는 경우에도) 거치게 된다.
따라서 먼저 토큰이 있는지 부터 검증을 해야 한다.
괜한 오류를 내서는 안 되기 때문이다.
그래서 jwt 예외가 발생했을 때 어떻게 에러를 반환 해줄까?
AuthenticationEntryPointImpl
package com.et.eachtogether.security;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;
@Component
public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException)
throws IOException {
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.setContentType("application/json");
response.setCharacterEncoding("UTF-8");
// to do: CommonErrorResponse
response.getWriter()
.write(request.getAttribute("error") != null ? request.getAttribute("error").toString() : authException.getMessage());
}
}
AuthenticationEntryPoint를 구현해 커스텀 할 수 있다.
간단하게 위에서 넣은 error 어트리뷰트가 있다면 해당 내용을 출력하고 아니면 authException을 그대로 출력했다.
물론 임시로 모든 파이프라인을 만들기 위해 내용을 그대로 출력하도록 한 것이고,
이후 에러 출력 형식을 정해서 리턴하도록 코드를 변경할 예정이다.
SecurityConfig
package com.et.eachtogether.config;
import com.et.eachtogether.security.AuthenticationEntryPointImpl;
import com.et.eachtogether.security.JwtAuthenticationFilter;
import com.et.eachtogether.security.JwtProvider;
import com.et.eachtogether.security.UserDetailsServiceImpl;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final UserDetailsServiceImpl userDetailsService;
private final JwtProvider jwtProvider;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationEntryPointImpl authenticationEntryPoint() { return new AuthenticationEntryPointImpl(); }
@Bean
public JwtAuthenticationFilter jwtAuthenticationFilter() { return new JwtAuthenticationFilter(jwtProvider, userDetailsService); }
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
return configuration.getAuthenticationManager();
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests((authorizeHttpRequests) ->
authorizeHttpRequests
.requestMatchers("/**").permitAll()
.anyRequest().authenticated()
);
http.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
http.exceptionHandling(handler -> handler.authenticationEntryPoint(authenticationEntryPoint()));
return http.build();
}
}
SpringSecurity에서는 위 필터와 핸들러를 등록해주며 오늘은 마무리하였다.
차회 예고
- CommonResponse, CommonErrorResponse
- 글로벌 예외 핸들러 개발
'개인프로젝트 > 스터디 앱' 카테고리의 다른 글
스터디 앱 04 - CommonResponse, record 클래스 (1) | 2024.11.11 |
---|---|
스터디 앱 02 - 5개월 만의 프로젝트, 기획 변경 (0) | 2024.11.06 |
스터디 앱 01 - 무엇을 어떻게 만들 것인가? (0) | 2024.06.03 |