Spring Security (+JWT + Redis + Cookie)

Marshall TY Yun·2023년 3월 13일
0

Spring-Security

목록 보기
1/1
post-thumbnail

1. 결론

  1. Spring Security + JWT(Access/Refresh Token) + Redis + Cookie 이용
  2. Access Token의 경우 local Storage 이용
  3. Refresh Token의 경우 Redis + Cookie 이용
  4. Api Call 시, axios api interceptors를 이용하여 jwt token validation 진행
  5. 화면 변경 시, RouteService.js에서 component를 이용해 jwt 체크 후 권한이 없다면 login화면 redirect 처리함

2. 개념

2.1 Spring Security
공식 문서 : https://spring.io/projects/spring-security#overview
https://docs.spring.io/spring-security/site/docs/5.4.0/reference/html5/

# Reference
1. 설정
https://gngsn.tistory.com/160
2. 개념
https://gngsn.tistory.com/160
3. Spring Security ver 5.7 (WebSecurityConfigAdapter deprecated)
https://velog.io/@pjh612/Deprecated%EB%90%9C-WebSecurityConfigurerAdapter-%EC%96%B4%EB%96%BB%EA%B2%8C-%EB%8C%80%EC%B2%98%ED%95%98%EC%A7%80

2.2 JWT
공식 문서 : https://jwt.io/

# Reference
0. JWT
https://velog.io/@junghyeonsu/%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%90%EC%84%9C-%EB%A1%9C%EA%B7%B8%EC%9D%B8%EC%9D%84-%EC%B2%98%EB%A6%AC%ED%95%98%EB%8A%94-%EB%B0%A9%EB%B2%95

https://velog.io/@yaytomato/%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%90%EC%84%9C-%EC%95%88%EC%A0%84%ED%95%98%EA%B2%8C-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EC%B2%98%EB%A6%AC%ED%95%98%EA%B8%B0#-%EB%B8%8C%EB%9D%BC%EC%9A%B0%EC%A0%80-%EC%A0%80%EC%9E%A5%EC%86%8C-%EC%A2%85%EB%A5%98%EC%99%80-%EB%B3%B4%EC%95%88-%EC%9D%B4%EC%8A%88

https://velog.io/@khy226/jwt%EB%A1%9C-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EA%B8%B0%EB%8A%A5-%EB%A7%8C%EB%93%A4%EA%B8%B0-React-Rails

1. Spring Security JWT 적용 (중요)
https://velog.io/@jkijki12/Spirng-Security-Jwt-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EC%A0%81%EC%9A%A9%ED%95%98%EA%B8%B0

2. redis
https://wildeveloperetrain.tistory.com/59

3. refresh token 적용
https://velog.io/@ehdrms2034/Spring-Security-JWT-Redis%EB%A5%BC-%ED%86%B5%ED%95%9C-%ED%9A%8C%EC%9B%90%EC%9D%B8%EC%A6%9D%ED%97%88%EA%B0%80-%EA%B5%AC%ED%98%84

4. react에 cookie 적용
https://5xjin.github.io/blog/react_jwt_router/

5. Cookie, Session 기본 지식
https://catsbi.oopy.io/0c27061c-204c-4fbf-acfd-418bdc855fd8

3. Flow

  1. 사용자 등록을 함
  2. Login
    : ID/PW 일치한다면 Access/Refresh JWT 발급 (Refresh의 경우 cookie값이 null 또는 expired 됬을 때 JWT 발급)
  3. API Call
    : Access Token을 이용해서 Authorization
    : Access Token Expired 시 Refresh Token 으로 Access Token 재발급
    : Refresh Token Expired 시 재로그인 필요
  4. 화면 이동
    : Access Token을 이용해서 Authorization
    : Access Token Expired시 Refresh Token을 이용해 Access Token 재발급
    : Access, Refresh Token 둘다 없거나 Expired 상태라면 /login 메뉴로 Redirect

4. 상세 개념

  1. Spring Security
    : FilterChain을 이용해 인증, 인가를 담당함 (Authentication, Authorization)

: 인증, 인가 처리를 여러개의 필터를 통해 진행하고 필터는 HttpSecurity 클래스에서 생성됨

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http.addFilterBefore(new JwtAuthenticationFilter(jwtProvider), UsernamePasswordAuthenticationFilter.class)
    ...

: 이렇게 설정 파일 별로 필터 목록을 갖게 된 후, 이 필터들은 WebSecurity 클래스에게 전달

: WebSecurity는 각각 설정 클래스로 부터 필터 목록들을 전달받고, 다시 FilterChainProxy를 생성자의 인자로 전달합

: 결국 FilterChainProxy는 각각의 설정 클래스 별(SecurityConfig1, SecurityConfig2)로 필터 목록들을 갖고 있는 형태가 됨

5. 상세 구현

[F : Front Side], [B : Backend Side]

Contents

  1. Front
    [F-1] Login, 회원등록 Page 생성
    : Login Page Component, Redux-Saga
    [F-2] Api Axios Post Call Intercept
    [F-3] 메뉴 이동 시 isAuth를 통해 권한 확인 및 권한 없을 시 login 메뉴 redirect
    [F-4] 권한에 따른 메뉴 출력 처리

  2. Back
    [B-1] Spring Security 설정
    [B-2] JWT 관련 설정
    [B-3] login, register관련 controller, service
    [B-4] Refresh 관련 설정
    [B-5] isAuth 관련 설정

상세

  1. [F-1] src/App.js
    : Route 추가 (login, userRegister)
        <Routes>
          <Route path="/login" element={<LoginPage/>} />
          <Route path="/userRegister" element={<UserRegister />} />
          <Route path="/*" element={<App/>} />
          {/* <Route path="app/*" element={<RouteService />} /> */}
        </Routes>
  1. [F-1] LoiginPage, UserRegisterPag 생성
/routes/LoginPage/index.js
/routes/UserRegister/index.js
const LoginPage = () => {

    //const {error} = useSelector(reducer => reducer.tAuthReducer);
    const dispatch = useDispatch();
    const navigate = useNavigate();
    const location = useLocation();
    const [state, setState] = useState({
        account: '',
        password: ''
    });

    const onHandleChange = e => {
        const {name, value} = e.target;
        setState({...state, [name]: value})
    }

    const onHandleSubmit = e => {
        e.preventDefault();
        dispatch(tAuthLogin({account: state.account, password: state.password, navigate}))
    }

return(
<form onSubmit={onHandleSubmit}>
	<ul className='form-btns'>
    	<li>
      		<button className='btn full primary' type='submit'>
                                        Login
            </button>
    	</li>                            
	</ul>
</form>
);
};

export default LoginPage;
  1. [F-1] Action, Saga, Reducer 생성
    : 로그인, 회원등록, 권한 Expired 상태 확인, 권한 Set 관련 Action, Saga, Reducer
//TAuthSagas.js

function* tAuthLoginToServer(action) {

    try {
        const response = yield call(getLoginRequest, action);

        if (response.status === 200){

            yield put(setTAuthIsAuthByLogin(response));
            yield localStorage.setItem('jwt-access', response.data.token);
            yield localStorage.setItem('authority', response.data.roles[0].role);
            setRefreshToken(response.data.refreshToken.value);
            navigate('/');
        } else if (response.data.statusCode === 0) {
            yield put(setTAuthIsAuthError(response))
            navigate('/login');
        }
    } catch (error) {
        message.error('Auth - SERVER ERROR');
    }
}

const getLoginRequest = async (request) =>
    await api( {
        method: 'post',
        url: '/api/auth/login',
        data: JSON.stringify(request.payload),
        headers: {'Content-Type': 'application/json'},
        //headers: {Authorization: `Bearer ${localStorage.getItem('jwt-access')}` 'Content-Type': 'application/json'},
        
    }).then((response) => {
        return response;
    }).catch((error) => {
        return error;
    });

export function* tAuthLoginSagas() {
    yield takeEvery(TAUTH_LOGIN, tAuthLoginToServer);
}

export default function* TAuthSagas() {
    yield all( [
        fork(tAuthLoginSagas),
        fork(tAuthRegisterSagas),
        fork(tAuthISAuthSagas),
    ]);
}

const TAuthReducer = (state = INIT_STATTE, action) => {
    switch(action.type) {
        case TAUTH_SET_IS_AUTH:
            return {...state, isAuth: action.payload.body.isAuth, error: ''}
        case TAUTH_SET_IS_AUTH_BY_LOGIN:
            return {...state, isAuth: true, error: ''}
        case TAUTH_SET_IS_AUTH_ERROR:
            removeCookieToken();
            return {...state, error: "Error" }
        case TAUTH_REGISTER_SUCCESS: { 
            return { ...state, loading: false};}
        case TAUTH_REGISTER_ERROR: { 
            return { ...state, loading: false};}
        default:
            return {...state};
    }
}

export default TAuthReducer;
  1. [B-1] Spring Security 설정

4.1 SecurityConfig.java

@Configuration
: @Configuration이라고 하면 설정파일을 만들기 위한 애노테이션 or Bean을 등록하기 위한 애노테이션이다.
: https://castleone.tistory.com/2

@EnableWebSecurity
: @EnableWebSecurity 어노테이션을 달면SpringSecurityFilterChain이 자동으로 포함됩니다.

@RequiredArgsConstructor
: final이 붙거나 @NotNull 이 붙은 필드의 생성자를 자동 생성해주는 롬복 어노테이션
: https://velog.io/@developerjun0615/Spring-RequiredArgsConstructor-%EC%96%B4%EB%85%B8%ED%85%8C%EC%9D%B4%EC%85%98%EC%9D%84-%EC%82%AC%EC%9A%A9%ED%95%9C-%EC%83%9D%EC%84%B1%EC%9E%90-%EC%A3%BC%EC%9E%85
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtProvider jwtProvider;

    @Bean
    public WebSecurityCustomizer webSecurityCustomizer() {
        return (web) -> web.ignoring().antMatchers("/images/**", "/js/**", "/webjars/**", "/h2-console/**", "/favicon.ico", "/login");
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {

        http
                // ID, Password 문자열을 Base64로 인코딩하여 전달하는 구조
                .httpBasic().disable()
                // 쿠키 기반이 아닌 JWT 기반이므로 사용하지 않음
                .csrf().disable()
                // CORS 설정
                .cors(c -> {
                            CorsConfigurationSource source = request -> {
                                // Cors 허용 패턴
                                CorsConfiguration config = new CorsConfiguration();
                                config.setAllowedOrigins(
                                        List.of("*")
                                );
                                config.setAllowedMethods(
                                        List.of("*")
                                );
                                return config;
                            };
                            c.configurationSource(source);
                        }
                )
                // Spring Security 세션 정책 : 세션을 생성 및 사용하지 않음
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                // 조건별로 요청 허용/제한 설정
                .authorizeRequests()
                    // 회원가입과 로그인은 모두 승인
                    .antMatchers("/api/auth/login").permitAll()
                    .antMatchers("/api/auth/refresh").permitAll()
                    .antMatchers("/api/auth/register").permitAll()
                    .antMatchers("/api/auth/isAuth").permitAll()
                    .and()
                    // /admin으로 시작하는 요청은 ADMIN 권한이 있는 유저에게만 허용
                    //.antMatchers("/admin/**").hasRole("ADMIN")
                    // /user 로 시작하는 요청은 USER 권한이 있는 유저에게만 허용
                    //.antMatchers("/user/**").hasRole("USER")
                .authorizeRequests()
                    .anyRequest().authenticated()
                    .and()
                // JWT 인증 필터 적용
                .addFilterBefore(new JwtAuthenticationFilter(jwtProvider), UsernamePasswordAuthenticationFilter.class)
                // 에러 핸들링
                .exceptionHandling()
                .accessDeniedHandler(new AccessDeniedHandler() {
                    @Override
                    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
                        // 권한 문제가 발생했을 때 이 부분을 호출한다.
//                            401(Unauthorized)
//                            상태: 클라이언트가 인증되지 않았거나, 유효한 인증 정보가 부족하여 요청이 거부됨
//                            예시: 사용자가 로그인되지 않은 경우
                        response.setStatus(403);
                        response.setCharacterEncoding("utf-8");
                        response.setContentType("text/html; charset=UTF-8");
                        response.getWriter().write("권한이 없는 사용자입니다.");
                    }
                })
                .authenticationEntryPoint(new AuthenticationEntryPoint() {
                    @Override
                    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
                        // 인증문제가 발생했을 때 이 부분을 호출한다.
//                            403(Forbidden)
//                            상태: 서버가 해당 요청을 이해했지만, 권한이 없어 요청이 거부됨
//                            예시: 사용자가 권한이 없는 요청을 하는 경우
                        response.setStatus(401);
                        response.setCharacterEncoding("utf-8");
                        response.setContentType("text/html; charset=UTF-8");
                        response.getWriter().write("AcessExpired");
                    }
                });
        return http.build();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }
}
  1. [B-2] JWT 설정
    5.1 JwtAuthenticationFilter.java (커스텀 필터)
/**
 * Jwt가 유효성을 검증하는 Filter
 */
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtProvider jwtProvider;

    public JwtAuthenticationFilter(JwtProvider jwtProvider) {
        this.jwtProvider = jwtProvider;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String token = jwtProvider.resolveToken(request);
        System.out.println("############ jwt filter api/auth/login : token : "+token);
        if (token != null && jwtProvider.validateToken(token)) {
            // check access token
            token = token.split(" ")[1].trim();
            Authentication auth = jwtProvider.getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(auth);
        }

        filterChain.doFilter(request, response);
    }
}
  1. [B-3] login, register관련 controller, service, [B-4], [B-5]
    6.1 JwtProvider.java (커스텀 provider)
// secret key 생성
// jwt token 생성 (Access, Refresh)
// 권한 정보 획득
// resolveToken : request header에서 token값 가져오기
// validateToken : Token 유효성, 만료일자 확인
// createAccessTokenByRefreshToken : refresh token을 통해 Access token 생성

6.2 User, UserDetailsService 작성
: SystemUser로 Entit 생성
: CustomUserDetails.java 생성 (Spring Security가 이용하는 객체)

6.3 Controller, Service, Repository 생성

//SystemUserController.java
// login
// register
// refresh (react의 api.interceptors를 통해 access token expired시 refresh 호출함)
// isAuth (시스템에서 경로 이동 시 현재 인증여부를 확인함, Aceess & Refresh 모두 없거나 Expired상태라면 false return)

    @PostMapping(value = "/login")
    public ResponseEntity<?> signin(HttpServletRequest request, @RequestBody SignRequestDto signRequestDto) throws Exception {

        return new ResponseEntity<>(signService.login(request, signRequestDto), HttpStatus.OK);

    }

    @PostMapping(value = "/refresh")
    public ResponseEntity<?> generateAccessJwtByRefreshToken(HttpServletRequest request) throws Exception {
        return new ResponseEntity<>(signService.generateAccessJwtByRefreshToken(request), HttpStatus.OK);
    }
   
       @PostMapping(value = "/register")
    public ResponseEntity<?> signup(HttpServletRequest request, @RequestBody SignRequestDto signRequestDto) throws Exception {

        try{
            signService.register(signRequestDto);
        } catch(JSONException e) {
            log.error("Json Exception : ", e);
            res.error("Something Wrong");
        } catch (Exception e) {
            log.error("Exception e : ", e);
            res.error("Something Wrong");
        }
        return res.send();
    }
    
    @PostMapping(value = "/isAuth")
    public ResponseEntity<?> isAuth(@RequestBody Map<String, String> bodyParam, HttpServletRequest request, Principal principal) throws Exception {

        JsonResponse<Map<Object, Object>> res = new JsonResponse<>("checkAuth");
        Map<Object, Object> map = new HashMap<>();

        try {
            if (principal != null) {
                map.put("isAuth", true);
            } else {
                if(signService.checkRefreshTokenValid(request)){
                    map.put("isAuth", true);
                }else{
                    map.put("isAuth", false);
                }

            }
            res.success(map);
        } catch (Exception e) {
            log.error("Exception e : ", e);
            throw new Exception(e);
        }
    
// SignService.java

// generateAccessJwtByRefreshToken : Refresh token으로 access token 생성
// checkRefreshTokenValid : refresh token valid여부 확인
// login
: DB에서 ID/PW 확인
: access token 생성
: cookie에 refresh token이 없거나 expired상태라면 refresh token 생성 + refresh token cookie에 담아서 return + redis에 refresh token 저장
// register : User 생성 및 권한 등록
@Service
public class JwtCookieUtil {

    public static Cookie createCookie(String cookieName, String value){
        Cookie token = new Cookie(cookieName,value);
        token.setHttpOnly(true);
        token.setMaxAge((int)JwtProvider.jwtRefreshExpire);
        token.setPath("/");
        return token;
    }

    public static Cookie getCookie(HttpServletRequest req, String cookieName){
        final Cookie[] cookies = req.getCookies();
        if(cookies==null) return null;
        for(Cookie cookie : cookies){
            if(cookie.getName().equals(cookieName))
                return cookie;
        }
        return null;
    }
}
@Service
public class RedisUtil {

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    public String getData(String key){
        ValueOperations<String,String> valueOperations = stringRedisTemplate.opsForValue();
        return valueOperations.get(key);
    }

//    public void setData(String key, String value){
//        ValueOperations<String,String> valueOperations = stringRedisTsemplate.opsForValue();
//        valueOperations.set(key,value);
//    }

    public void setDataExpire(String key, String value, long duration){
        ValueOperations<String,String> valueOperations = stringRedisTemplate.opsForValue();
        Duration expireDuration = Duration.ofSeconds(duration);
        valueOperations.set(key,value,expireDuration);
    }

    public void deleteData(String key){
        stringRedisTemplate.delete(key);
    }

}
  1. [F-2] Api Axios Post Call Intercept
// 참고
// https://velog.io/@wooya/axios-interceptors%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%B4-token%EB%A7%8C%EB%A3%8C%EC%8B%9C-refreshToken-%EC%9E%90%EB%8F%99%EC%9A%94%EC%B2%AD 
import axios from "axios";

// url 호출 시 기본 값 셋팅
const api = axios.create({
    //timeout: 2000
    //headers: { "Content-type": "application/json" }, // data type
});

// Add a request interceptor
api.interceptors.request.use(
  function (config) {
    const token = localStorage.getItem("jwt-access");

    //요청시 AccessToken 계속 보내주기
    if (!token) {
      config.headers.accessToken = null;
      //config.headers.refreshToken = null;
      return config;
    }

    if (config.headers && token) {
      //const { accessToken, refreshToken } = JSON.parse(token);
      config.headers.authorization = `Bearer ${localStorage.getItem("jwt-access")}`;
      //config.headers.refreshToken = `Bearer ${refreshToken}`;
      return config;
    }
    // Do something before request is sent
    console.log("api.interceptors.request.use start", config);
  },
  function (error) {
    // Do something with request error
    console.log("api.interceptors.request.use error", error);
    return Promise.reject(error);
  }
);

// Add a response interceptor
api.interceptors.response.use(
    
  function (response) {
    // Any status code that lie within the range of 2xx cause this function to trigger
    // Do something with response data
    console.log("api.interceptors.response.use");
    console.log("get response", response);
    return response;
  },
  async (error) => {
    console.log("api.interceptors.response.error : ",error);
    const {
      config,
      response: { status },
    } = error;
        const originalRequest = config;
        //const refreshToken = await localStorage.getItem("refreshToken");
        // token refresh 요청
        const { data } = await axios.post(
          `/api/auth/refresh`, // token refresh api
          {},
          {}
        );

        console.log("api.interceptors.response AccessExpired data.accessToeken : ", config);

        await localStorage.setItem('jwt-access', data.token);
        originalRequest.headers.authorization = `Bearer ${localStorage.getItem('jwt-access')}`;
        // 401로 요청 실패했던 요청 새로운 accessToken으로 재요청
        return axios(originalRequest);
      }
    }
    // Any status codes that falls outside the range of 2xx cause this function to trigger
    // Do something with response error
    console.log("response error", error);
    return Promise.reject(error);
  }
);

export default api;
  1. [F-3] 메뉴 이동 시 isAuth를 통해 권한 확인 및 권한 없을 시 login 메뉴 redirect
import React, {useEffect, useState} from "react";
import {Redirect} from "react-router-dom";
import { useDispatch, useSelector } from "react-redux";
import {tAuthIsAuth} from "../../actions";
import { Navigate } from "react-router-dom";


const WithAuth = ({ children }) => {
     const authority = localStorage.getItem('authority');
     const dispatch = useDispatch();
     const {isAuth} = useSelector(reducer => reducer.tAuthReducer);
    useEffect(() => {
        dispatch(tAuthIsAuth({}));
    }, [dispatch]);

    useEffect(() => {
        if(isAuth === false) {
            window.localStorage.removeItem('authority');
        }
    }, [isAuth]);

    if (authority) {
      // user has authority
      return children;  
    }
    return <Navigate to="/login" />;
    
  };

export default WithAuth;

0개의 댓글