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
: 인증, 인가 처리를 여러개의 필터를 통해 진행하고 필터는 HttpSecurity 클래스에서 생성됨
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.addFilterBefore(new JwtAuthenticationFilter(jwtProvider), UsernamePasswordAuthenticationFilter.class)
...
: 이렇게 설정 파일 별로 필터 목록을 갖게 된 후, 이 필터들은 WebSecurity 클래스에게 전달
: WebSecurity는 각각 설정 클래스로 부터 필터 목록들을 전달받고, 다시 FilterChainProxy를 생성자의 인자로 전달합
: 결국 FilterChainProxy는 각각의 설정 클래스 별(SecurityConfig1, SecurityConfig2)로 필터 목록들을 갖고 있는 형태가 됨
[F : Front Side], [B : Backend Side]
Front
[F-1] Login, 회원등록 Page 생성
: Login Page Component, Redux-Saga
[F-2] Api Axios Post Call Intercept
[F-3] 메뉴 이동 시 isAuth를 통해 권한 확인 및 권한 없을 시 login 메뉴 redirect
[F-4] 권한에 따른 메뉴 출력 처리
Back
[B-1] Spring Security 설정
[B-2] JWT 관련 설정
[B-3] login, register관련 controller, service
[B-4] Refresh 관련 설정
[B-5] isAuth 관련 설정
<Routes>
<Route path="/login" element={<LoginPage/>} />
<Route path="/userRegister" element={<UserRegister />} />
<Route path="/*" element={<App/>} />
{/* <Route path="app/*" element={<RouteService />} /> */}
</Routes>
/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;
//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;
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();
}
}
/**
* 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);
}
}
// 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);
}
}
// 참고
// 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;
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;