목차
1. JWT 인증방식 동작 원리
2. SecurityConfig 추가, DB연결, 회원가입 추가
3. 회원가입 구현, AuthenticationFilter, AuthenticationManager 구현
4. DB 저장 구현, UserDetailService, UserDetail 구현
5. 로그인 성공시 토큰 발급 구현 JWTUtil
6. 토큰 검증 필터 구현 JWTFilter
7. CORS 구현
내부 회원가입 로직은 세션방식과 JWT 방식의 차이가 없다
로그인은 경로로 요청이 오면 본래에는 스프링 시큐리티가 전부 처리 해줬었는데, JWT 같은 경우는 일련의
Filter
나Manager
들을 전부 직접 구현을 해줘야 한다.
로그인 경로로 POST 요청이 들어오면
UsernamePasswordAuthenticationFilter
로 코드를 작성 후 이후AuthenticationManager
내부에 아이디와 비밀번호를 전달한 후에 내부적으로 검증을 한다. 검증을 하는 방법은DB
안에 있는User
정보를 꺼내와서UserDetailService
가UserDetails
객체에 담아서Authentication Manager
에서 검증을 하게 된다.
기존의 Session 방식과 차이점
만약 로그인이 성공 한다면 기존의 Session 방식은 서버의 Session에 저장을 하지만 JWT 방식은 Session에 저장하지 않고
SuccessfulAuthentication
이라는 메서드를 통해서JWTUtill
에서 토큰을 만든 후 우리에게 응답Response
을 해준다.
기존의 Session 방식과 공통점
Filter
Manager
UserDetailService
를 거치는 일련의 과정은Session
방식과 비슷하다
JWT Filter
를 통해 요청 헤더에서 JWT
를 찾아 검증을 하고, 일시적 요청에 대한 Session
을 생성한다. ( 생성된 세션은 요청이 끝나면 소멸 )
로그인 과정에서 생성된 토큰으로 특정한 ADMIN 경로 혹은, 게시판에 접근할 때에는 요청을 할때
헤더
에 토큰을 넣어서 진행해야 한다 반드시.
먼저 특정한 경로로 요청이 오면
SecurityAuthenticationFilter
로 검증을 먼저 진행 하고, 그 다음JWTFilter
라는 것을 직접 커스텀 해서 검증을 진행 해야 한다!!
토큰이 알맞게 존재하고, 정보가 일치한다고 하면,
JWTFilter
에서 강제로 반 일시적인Session
을 만들게 되는데SecurityContextHolderSession
에 해당한다. 특정 경로로 요청이 가면, 해당 세션이 있기 때문에 접근권인가를 받을 수 있다. 단 단 하나의 요청에 대해서만 세션이 만들어 지고, 해당 요청이 끝나면 세션이 사라지게 된다. 만약에 다시 다른 요청이 들어오게 되면 헤더에 있는 토큰을 이용해 동일한 아이디여도 다시 세션을 만들게 되고, 요청이 끝나면 사라지게 된다.
사용자 요청으로 login Post 요청이 id와 password를 가지고 해당 경로로 request가 오면
UsernamePasswordAuthenticationFilter
가username
password
를 꺼내서 로그인을 진행 하게 되는데 이 데이터를AuthenticationManager
에게 넘겨 준다.AuthenticationManager
는DB
로 부터 회원 정보를 가지고 와서 검증을 진행하고, 검증 후에successfulAuthentication
이 동작 하게 된다. 이 부분에서 JWT 토큰을 생성하여 사용자에게 응답하게 된다. 성공 못할 시엔unsucessful
이 나오게 되는데 이 부분은 단순히 JWT 토큰을 생성하지 않고 401 응답처리를 하는 것으로 처리하면 된다.
스프링 시큐리티는 클라이언트의 요청이 여러 필터를 거쳐
DispatcherServlet(Controller)
로 향하는 중간 필터에서 요청을 가로챈 후 검증 (인증/ 인가
) 를 진행하게 된다.
클라이언트 요청 -> 서블릿 필터 -> 서블릿( 컨트롤러)
스프링 시큐리티는 전반적으로 톰캣이라는
Servlet Container
위에서 작동한다. 클라이언트의 요청이 오면 톰캣이라는 서블릿 필터를 전부 통과 해서 최종적으로 스프링 부트에 전달되게 된다. 이 필터를 활용해서 시큐리티를 구체적으로 구현한다.
Delegating Filter Proxy
서블릿 컨테이너 (톰캣)에 존재하는 필터 체인에
DelegationFilter
를 등록한 뒤 모든 요청을 가로챈다.
서블릿 필터 체인의 DelegationFilter
-> Security
필터 체인 ( 내부 처리 후) -> 서블릿 필터 체인의 DelegationFilter
가로 챈 요청은
SecurityFilterChain
에서 처리 후 상황에 따른 거부, 리디렉션,서블릿으로 요청 전달을 진행한다.
권한이 없으면 거부한다 거나, 리디렉션 하는 등의 처리를 한다.
SecurityFilterChain의 필터 목록과 순서
Form 로그인 방식에서 UsernamePasswordAuthenticationFilter
Form 로그인 방식에서는 클라이언트단이 username 과 password 를 전송한 뒤 Security 필터를 통과 하는데
UsernamePasswordAuthentication
필터에서 회원 검증 진행을 시작한다.
(회원 검증의 경우UsernamePasswordAuthentication
이 호출한AuthenticationManager
를 통해 진행하며 DB에서 조회한 데이터를UserDetailService
를 통해 받음)
진행하는 예제에서는
SecurityConfig
에서formLogin
방식을disable
했기 때문에 기본적으로 활성화 되어 있는 해당 필터에서는 동작하지 않는다. 따라서 로그인을 하기 위해서는 필터를 직접 구현해야 한다.
스프링 공식 문서를 참고 하면
UsernamePasswordAuthenticationFilter
는HttpSecurityFormLogin
방식을 쓰고 있는 것을 확인 할수 있다.
예제에서 구현 할 부분(로그인 로직)
SecurityConfig
에 등록JWT 발급과 검증
필터와 DB 검증을 거쳐 로그인에 성공하면 JWT을 발급해주는 로직도 필요하고, 해당 JWT 토큰을 가지고 접근시 토큰이 유효한지 토큰 자체를 검증하는 로직 또한 추가로 만들어줘야 한다. 해당 발급,검증 클래스는
JWTUtil
이고sucessfulhandler
나filter
에서 사용할 수 있게 컴포넌트로 분리해놓는다.
점으로 구분되어 HEADER PAYLOAD VERIFY SIGNATURE로 구분한다.
Header
.role()
,.username()
JWT의 특징은 내부 정보를 단순 BASE64 방식으로 인코딩 하기 때문에 외부에서 쉽게 디코딩 할수 있다. 따라서 외부에서 열람해도 되는 정보만 담다야 하며 토큰 자체의 발급처를 확인하기 위해서 사용한다.
(지폐와 같이 외부에서 그 금액을 확인하고 금방 외형을 따라 만들 수 있지만 발급처에 대한 보장 및 검증은 확실하게 해야하는 경우에 사용한다. 따라서 토큰 내부에 비밀번호와 같은 값 입력 금지)
발급처를 검증할 수 있는 시스템은 확실히 구축되어 있기 때문에 외부에서 role 값을 임의로 변경하게 되면 signature
내에 복호화된 키값이 달라 이를 방질할 수 있지만 디코딩은 쉬워 내부의 값은 뜯어보기 쉽다.
JWT 암호화 방식
암호화 키 저장
암호화 키는 하드코딩 방식으로 구현 내부에 탑재하는 것을 지양하기 때문에 변수 설정 파일에 저장한다
spring.jwt.secret=
임의의 값
위의 설정 명도 임의로 정한 값이며 암호도 최대한 길게 아무렇게나 작성한다.
디렉토리 구조
AdminController
@Contoller
public class AdminController{
@GetMapping("/admin")
public String adminP(){
return "admin Controller"
}
}
JoinController
@RestContoller
@RequiredArgsConstructor
public class JoinController{
private final JoinService joinService
@GetMapping("/join")
public String adminP(JoinDto joinDto){
joinService.joinProcess(joinDto);
return "ok";
}
}
Dto
로 받고UserRepository
에 저장 할때엔UserEntity
로 변환해서 저장 한다
MainController
@Contoller
public class MainController{
@GetMapping("/")
public String adminP(){
return "admin Controller"
}
}
JoinService
@Service
@RequiredArgsConstructor
public class JoinService{
private final UserRepository userRepository;
private final BCyrptPasswordEncoder bCyrptPassowrdEncoder;
public void joinService(JoinDto joinDto){
UserEntity userEntity= new UserEntity;
userEntity.setName(joinDto.getName());
userEntity.setPassword(bCyrptPasswordEncoder(joinDto.getPassword()));
if(userRepository.findByUserName(joinDto.getName())){
reutrn;
}
userRepository.save(userEntity)
}
}
userRepository
에서findByUsername
을 구현해놓고 이로 중복검사를 진행 한다. true일 경우 빈 값을 리턴 한다.
@Repository
public class UserRepository extends JpaRepository<UserEntity,Long>{
boolean existByUsername(String username);
UserEntity findByUsername(String username);
}
existByUsername
은 중복 유무만 알면 되지만findByUserName
은UserEntity
를 반환해야 한다.
public class UserEntity{
@Id @GeneratedValue(strategy= GenerationTyp.AUTO)
Long id;
private String password;
private String email;
private String role;
}
@Configuration
@EnableWebSecurity
public class SecurityConfig{
@Bean
pulbic BCyrptPasswordEncoder bCyrptPasswordEncodr(){
return new bCryptPasswordEncoder();
}
@Bean
public SecurityFilterChain SercurityFilterChain(HttpSercurity http) trhows Exception{
http
.formLogin((auth)->auth.formLogin().disable())
.httpBasic((auth)->httpBaisc().disable())
.csrf((auth)->csrf.disable());
http
.authorizationRequest(
(auth)->auth
.requestMatchers("/login","/","/join")).permitAll()
.requestMatchers("/admin").hasAnyRole("ADMIB")
.anyRequest().authenticated()
);
http
.sessionManagement((session)->session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
return http.build();
}
}
crsf().disable()
이 무엇이고 왜 하는 거지?
CSRF(Cross-Site-Request-Forgery)
는 사용자가 자신의 의도와 무관하게 공격자가 의도한 행위 (ㅊCRUD) 를 특정 웹 사이트에 요정하게 만드는 공격이다.
stateless
한 인증 방식을 따른다.CSRF
토큰을 저장할 수 있는 세션이 존재하지 않기 때문에, crsf().disable()
방어 기능을 비활성화 하고, 서버에서 다른 방식의 보안 절차를 수행한다.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
는 무엇인가요? 왜 하나뇽?STATELESS
로 관리한다httpBasic().disable()
은 뭔가요? 왜 하나요?disable()
한다.JWT의 STATELESS
LoginFilter
@AllArgsConstructor
public class LoginFilter extends UsernamePassowrdAuthenticationFilter{
private final AuthenticationManager authenticationManager;
@Override // 1번 인증
public Authentication attemptAuthentication(HttpServletReqeust request, HttpServletResponse response) throws AuthenticationException{
String useranme= obtaiUsername(request);
String password= obtaionPassword(reqeust);
}
UsernamePasswordAuthenticationToken usernamePasswordAuthentiacationToken = new UsernamePasswordAuthenticationToken(username,password,null);
return authenticationManager.authenticate(usernamePasswordAuthenticationToken);
// 토큰 생성 후 , 아이디 패스워드 담아서 매니저에게 전달
}
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authentication) {
sout("success");
}
@Override
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) {
sout("fail");
}
여기서 LoginFilter는 시큐리티 필터 체인의 일부로 동작하는데, 사용자 인증 직전 단계에 위치하여 아래와 같은 기능을 주로 한다.
AuthenticationManager
에 인증 토큰을 전달하여 실제 인증 위임 AutehnticationManager
의 역할
결론
따라서 LoginFilter 가 AuthenticationManager 의존성 주입 받음
attemptAuthentication()
successfulAuthentication()
unsuccessfulAuthentication()
requiresAuthentication()
attemptAithentication()
에서 인증 후sucessfulAutentication()
에 인증 성공 로직을 수행하게 된다.
SecurityConfig에 추가할 코드들
private final AuthenticationConfiguration authenticationConfiguration;
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) thows Exception{
configuration.getAuthenticationManager();
}
http
.addFilter(new LoginFilter(authenticationManager(authenticationConfiguration)), UsernamePasswordAuthenticationFilter.class);
//addFilterAt() , addFilterAfter(), addFilterBefore() 여러가지가 있는데
// 지금 구현하는 예제에서는 unsernamePasswordAuthenticationFilter를 대체해서 사용할 것이기 때문에
// 딱 해당 위치에 대체하는 addFilterAt을 쓴다
// 첫 번째 파라메터에는 직접 만든 필터, 두번 째 파라메터엔 대체할 위치인 필터 클래스를 적는다
config 파일에
AuthenticationManager
에AuthenticationConfiguration
을 주입 해야 한다.
추가로 앞서 작성한 필터도 직접 추가 해야한다.
AuthenticationConfiguration은 인증 설정 정보라고 생각하면 된다.
따라서 SecurityConfig
에서 AuthenticationConfiguration
을 주입 받고 AuthnticationManager
에 넣는다
CustomerUserDetails
UserDetail을 상속 받고 Ovveride 해야한다
권한은 컬렉션을 만들어 추가하고 나머지는
userEntity
에서get
한다.
이외 속성들은true
로 해주면 된다.
public class CustomerUserDetails implements UserDetails{
private final UserEntity userEntity;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> collection = new ArrayList<>();
collection.add(new GrantedAuthority() {
@Override
public String getAuthority() {
return userEntity.getRole();
}
});
return collection;
}
@Override
public String getPassword() {
return userEntity.getPassword();
}
@Override
public String getUsername() {
return userEntity.getUsername();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
}
CustomerUserDetailService
loadByUsername
메서드를Override
해야한다
@Service
@RequiredArgsConstructor
public class CustomerUserDetailService implements UserDetailsServce{
private final UserRepository userRepository = new UserRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFounException{
UserEntity userData = userRepository.findByUsername(username);
if(username != null){
return new CustomUserDetail(userData);
}
}
}
로그인
@Component
public class JWTUtil {
private SecretKey secretKey; //JWT 토큰 객체 키를 저장할 시크릿 키
public JWTUtil(@Value("${spring.jwtseretkey}") String secret) {
this.secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), Jwts.SIG.HS256.key().build().getAlgorithm()
);
}
public String getUsername(String token) {
return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("username",String.class);
}
public String getRole(String token) {
return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("role", String.class);
}
public boolean isExpired(String token) {
return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().getExpiration().before(new Date());
}
public String createJwt(String username, String role, Long expiredMs) {
return Jwts.builder()
.claim("username", username)
.claim("role", role)
.issuedAt(new Date(System.currentTimeMillis()))
.expiration(new Date(System.currentTimeMillis() + expiredMs))
.signWith(secretKey)
.compact();
}
}
JWT 검증 필터
스프링 시큐리티
filter chain
에 요청에 담긴 JWT를 검증하기 위한 커스텀 필터를 등록 해야 한다. 해당 필터를 통해 요청 헤더Authorization
키에 JWt 가 존재하는 경우 JWT를 검증하고 강제로SecurityContextHolder
에 세션을 생성한다. ( 이 세션은STATELESS
상태로 관리되기 때문에 해당 요청이 끝나면 소멸 된다.)
JWTFilter
@RequiredArgsConstructor
public class JWTFilter extends OncePerRequestFilter{
private final JWTUtil;
@Override
protetcted void doFilterInternal(HttpServletRequest request, HttpResponse response, FilterChain filterChains) throws Exception{
String authorization = request.getHeader("Authorization");
if(authorization ==null ! authorization.startsWith("Bearer ")){
Sout("tokne null");
filterChain.doFilter(request,response);
return;
}
sout("authorization now");
String token=authorization.split(" ")[1];
if(jwtUtil.isExpired(token)){
sout("token expired);
filterChain.doFilter(request,response);
return;
}
UserEntity userEntity = new UserEntity();
userEntity.setUsername(jwtUtil.getUsername(token));
userEntity.setPassword(jwtUtil.getPassword("temp"));
userEntity.setRole(jwtUtil.setRole(role));
CustomerUserDetails customerUserDetails = new CustomerUserDetails(userEntity);
Authnetication authToken = new UsernamePasswordAuthenticationToken(customUserDetails,null,customUserDetails.getAuthorities());
SecurityContextholder.getContext().setAuthentication(authToken);
filterChain.doFilter(request,response);
}
}
- request Header 에서 Authorziation 부분만 추출한 뒤
- Bearer 로 시작하지 않거나 null 이면 다음 필터를 실행 시킨다
- 문두 부분을 제거한 문장만 추출한 뒤
- 만료 검증을 한다. 이때 만료 검증은
JWTUtil
에서 미리 구현해 놓은 메서드를 활용한다- 만료시 다음 필터를 동작 시킨다
- 토큰 유효검증이 끝나면 JWTUtil 을 이용하여 id,password를 얻어 UserEntity 를 생성한다
- UserEntity를 UserDetails에 담는다,
- userDetails를 활용하여 인증 토큰을 발급한다.
- 세션에 등록한다. (스프링 시큐리티 컨텍스트 홀더)
UserDetail에 회원 정보 객체 담는 이유:
스프링 시큐리티 인증 토큰을 생성해서 세션에 등록하는 이유:
CORS crossOriginResourceSharing 는 리소스가 다른 출처의 페이지나 어플리케이션에서 사용될 수 읶도록 보안상의 제약을 완화 해주는 메커니즘이다.
@Configuration
public class CorsMvcConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry corsRegistry) {
corsRegistry.addMapping("/**")
.allowedOrigins("http://localhost:3000");
}
}
혹시 해당 게시물의 실제 코드를 볼 수 있는 저장소가 있을까요? import 된 것도 세세하게 보고싶어서 여쭤봅니다!