Spring Security / jwt 토큰을 이용한 '로그인'을 postman으로 검증 후, 바로 프론트 단과 연결하였다.
React 환경으로 작업을 하는데, 수많은(코드 복호화 안 됨, 응답 못함 등등...) 에러가 있었지만....
당시엔 수정하느냐고 기록을 하지 못하였다.
그리하여 오늘은 복습 겸 어떤식으로 구성했는지 남겨보려고 한다.
일단 서버에서 인증 성공 후 토큰을 생성을 위한 onAuthenticationSuccess 메소드에서 프론트에 넘겨줄 값을 담아준다.
@Configuration
public class CustomAuthSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler {
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws ServletException, IOException {
System.out.println(" 로그인 성공 - JWT 토큰 생성 중...");
MemberDTO member = ((MemberDTO) authentication.getPrincipal());
HashMap<String, Object> responseMap = new HashMap<>();
JSONObject jsonValue = null;
JSONObject jsonObject;
if(member.getMemberRole().equals("LIMIT")){
System.out.println("LIMIT으로 왔는지");
responseMap.put("userInfo", jsonValue);
responseMap.put("status", 500);
responseMap.put("message","휴먼상태인 계정입니다.");
}
else{
String token = TokenUtils.generateJwtToken(member);
System.out.println("토큰 생성 됐는지 token = " + token);
// tokenDTO response
TokenDTO tokenDTO = TokenDTO.builder()
.memberName(member.getUsername())
.accessToken(token)
.grantType(AuthConstants.TOKEN_TYPE)
.memberRole(member.getMemberRole())
.memberId(member.getMemberId())
.build();
jsonValue = (JSONObject) ConvertUtil.convertObjectToJsonObject(tokenDTO);
System.out.println("userInfo에 담기는 jsonValue = " + jsonValue);
responseMap.put("userInfo", jsonValue);
responseMap.put("status", 200);
responseMap.put("message", "로그인 성공");
}
jsonObject = new JSONObject(responseMap);
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json");
PrintWriter printWriter = response.getWriter();
printWriter.println(jsonObject);
printWriter.flush();
printWriter.close();
}
}
builder()를 이용하여 tokenDTO와 매칭시켜 담아주는데 나는 회원 이름 , JWT 토큰, 토큰 타입, 회원 권한, 회원 ID 등이 필요하여 요것만 담아줬다.
최초 로그인은 시, 서버에 요청 url 설정을 해준다 ( `http://localhost:8080/api/v1/auth/login`)
(localhos:8080을 암호화 하여 변수로 담아서 사용도 가능하지만 생략)
그 후 return 구문에 async / await 방식으로 fecth로 데이터를 넘겨준다. (메서드, 헤더, 바디 설정)
body에는 id값인 email과 password를 body에 담아줬고, JSON.stringfy 는 객체를 json 문자열 형식으로 바꿔주는 메소드이고 적용해줘야 서버에서 받을 수 있다.
export const callLoginAPI = ({ form }) => {
const loginURL = `http://localhost:8080/api/v1/auth/login`
console . log ( 'form' , form );
return async ( dispatch , getState ) => {
const result = await fetch ( loginURL ,{
method : 'POST' ,
headers : {
'Content-Type' : 'application/json' ,
Accept : '*/*' ,
'Access-Control-Allow-Origin' : '*' , // 모든 도멘인에서 접근할 수 있음을 의미
},
body : JSON . stringify ({
email : form . email ,
password : form . password
}),
}). then ( res => res . json ());
console . log ( '로그인 시도 후 반환 받은 데이터 result : ' , result );
if ( result . status == 200 ) {
console . log ( '로그인 성공 result.status : ' , result . status );
window . localStorage . setItem ( 'accessToken' , result . userInfo . accessToken );
dispatch ({ type : POST_LOGIN , payload : result });
alert ( result . message );
return true ;
} else {
console . log ( '로그인 실패 : ' , result . status );
alert ( result . failType );
return false ;
}
}
}
요청이 성공적으로 보내지면 then절에 서버에서 보낸 jwt token 및 데이터들이 담기고,
서버에서 받은 데이터를 window . localStorage . setItem ( 'accessToken' , result . userInfo . accessToken ); 를 이용하여 세팅해준다.
이후 dispatch 요청을 보내는데 type은 미리 정의해 둔 주문서 개념으로 POST_LOGIN 에 맞는 주문서가 동작하고, 서버에서 받은 result를 payload에 담아 보내준다.
아래는 Redux를 사용하여 상태관리하는 MemberModules.
import { createActions , handleActions } from 'redux-actions' ;
// 초기값 설정
const initialState = [];
// 액션 정의 (액션 식별에 사용)
export const POST_REGISTER = 'member/POST_REGISTER' ;
export const POST_LOGIN = 'member/POST_LOGIN' ;
export const GET_MEMBER = 'member/GET_MEMBER' ;
// 액션 생성자 자동 생성 함수
const actions = createActions ({
[ POST_REGISTER ] : () => {},
[ POST_LOGIN ] : () => {},
[ GET_MEMBER ] : () => {}
});
const memberReducer = handleActions ({ // 리듀서 생성
[ POST_REGISTER ] : ( state , { payload }) => {
console . log ( 'memberReducer POST_REGISTER의 state : ' , state );
console . log ( 'memberReducer POST_REGISTER의 {payload} : ' , payload );
return payload ;
},
[ POST_LOGIN ] : ( state , { payload }) => {
console . log ( 'memberReducer POST_LOGIN state : ' , state );
console . log ( 'memberReducer POST_LOGIN {payload} : ' , payload );
return payload ;
},
[ GET_MEMBER ] : ( state , { payload }) => {
console . log ( 'memberReducer SET_MEMBER_LIST state : ' , state );
console . log ( 'memberReducer SET_MEMBER_LIST {payload} : ' , payload );
return payload ;
}
}, initialState );
export default memberReducer ;
memberReducer의 POST_LOGIN이 동작하고, result를 담았던 payload가 return 된다.
(두 번째 인자로 초기 값을 넣는 이유는 처음 호출될 때 상태를 빈 배열로 하기 위함)
import { combineReducers } from "redux" ;
import productReducer from "./productReducer" ;
import categoryReducer from "./CategoryModuls" ;
import memberReducer from "./MemberModule" ;
export default combineReducers ({
// product: productReducer,
category : categoryReducer ,
member : memberReducer
})
추가로 위의 memberReducer를 Reducer를 묶어서 관리하는 combineReducers에 member라는 key로 관리해주고,
import { createStore , applyMiddleware } from "redux" ;
import { thunk } from "redux-thunk" ;
import productReducer from "./redux/modules/productReducer" ;
import rootReducer from './redux/modules'
let store = createStore ( rootReducer , applyMiddleware ( thunk ))
export default store ;
스토어에 저장! (rootReducer는 combineReducers를 통해 결합된 결과)
이제 언제든 스토어에서 꺼내쓸 수 있다.
사실 최초에 '로그인' 버튼을 눌렀을 때 아래의 함수가 동작하고 dispatch로 위의 로직(서버로 요청 및 리턴)이 발동된다.
const onClickLoginHandler = async () => {
const isLoginSuccess = await dispatch ( callLoginAPI ({ form }));
if ( isLoginSuccess ) {
console . log ( 'isLoginSuccess : ' , isLoginSuccess );
// 로그인 하고 decoding 해야 함.
const token = decodeJwt ( window . localStorage . getItem ( "accessToken" ));
console . log ( 'token : ' , token );
console . log ( 'token.sub : ' , token ?. sub );
if ( token ){
dispatch ( callGetMemberAPI ({ memberId : token . sub }));
navigate ( "/" );
} else {
console . error ( "유효하지 않은 토큰!!" );
}
}
}
만약 isLoginSuccess가 true 라면 받아온 토큰을 꺼내준다.
여기서 시간 소모가 많았는데, const token = decodeJwt ( window . localStorage . getItem ( "accessToken" )); 이 구문을 전역으로 설정해둬서 최초 로그인 시 토큰을 찾을 수 없다는 경고가 뜨고, 새로고침 후에는 정상적으로 로그인 되었음!!
아무리 리액트가 왔다갔다 한다고 해도 흐름이란 게 있는건데.... 마음만 급했던 것 같다.
무튼... 토큰이 잘 출력되는지 확인하고, 만약 토큰이 있다면 dispatch ( callGetMemberAPI ({ memberId : token . sub })); callGetMemberAPI로 token.sub(memberId)를 담아 호출해준다.
export const callGetMemberAPI = ({ memberId }) => {
const memberRequestURL = `http://localhost:8080/api/v1/member/ ${ memberId } ` ;
return async ( dispatch , getState ) => {
const result = await fetch ( memberRequestURL ,{
method : 'GET' ,
headers : {
'Content-Type' : 'application/json' ,
Accept : '*/*' ,
Authorization : 'Bearer' + window . localStorage . getItem ( 'accessToken' ),
},
}). then (( res ) => res . json ());
console . log ( 'callGetMemberAPI result : ' , result );
dispatch ({ type : GET_MEMBER , payload : result });
}
}
이 구문은 받아온 토큰의 memberId를 이용하여 로그인한 회원의 전체 정보를 호출하는 구문이다.
받아온 데이터 결과를 다시 dispatch ({ type : GET_MEMBER , payload : result }); 호출한다.
검증 결과, result key 값에 회원 정보가 담겨있다.
이제 더 필요한 정보가 있으면 서버에서 추가하면 되고, 프론트에서는 필요한 곳에 꺼내서 사용하면 되겠다.
<로그인 전체 로직> ( 아직 좀 더 수정 필요!)
import './login.css'
import { useNavigate } from 'react-router-dom' ;
import mainLogo from '../../assets/images/mainLogo.png' ;
import { useEffect , useState } from 'react' ;
import { useSelector , useDispatch } from 'react-redux' ;
import { callGetMemberAPI , callLoginAPI , callMemberListAPI } from '../../apis/MemberAPI' ;
import decodeJwt from '../../utils/tokenUtils' ;
function Login () {
// useSelect로 store에 저장된 memer를 꺼내 사용하겠다~~
const loginMember = useSelector ( state => state . member );
const [ form , setForm ] = useState ({
email : '' ,
password : ''
});
const navigate = useNavigate ();
const dispatch = useDispatch ();
useEffect (() => {
if ( loginMember . state === 200 ){
console . log ( 'useEffect의 loginMember : ' , loginMember );
}
}, [ loginMember ]);
const onChangeHandler = ( e ) => {
setForm ({
... form ,
[ e .target.name]: e . target . value
});
};
const onClickLoginHandler = async () => {
const isLoginSuccess = await dispatch ( callLoginAPI ({ form }));
if ( isLoginSuccess ) {
console . log ( 'isLoginSuccess : ' , isLoginSuccess );
// 로그인 하고 decoding 해야 함.
const token = decodeJwt ( window . localStorage . getItem ( "accessToken" ));
console . log ( 'token : ' , token );
console . log ( 'token.sub : ' , token ?. sub );
if ( token ){
dispatch ( callGetMemberAPI ({ memberId : token . sub }));
navigate ( "/" );
} else {
console . error ( "유효하지 않은 토큰!!" );
}
}
}
return (
<>
< div >
< div className = "loginLayout" >
< div className = "loginContainer" >
< div className = "loginMainLogo" >
< img src = { mainLogo } alt = "메인 로고" onClick = { () => navigate ( '/' ) } />
</ div >
< div className = "loginForm" >
< label style = { { fontWeight : 'bold' } } > 환영합니다 고객님! </ label >
< div className = "loginInput" >
< input
type = "text"
placeholder = '아이디 (이메일)'
name = "email"
onChange = { onChangeHandler }
/>
< input
type = "password"
placeholder = '비밀번호 입력'
name = "password"
onChange = { onChangeHandler }
/>
</ div >
< div className = "loginBtn" >
< button
onClick = { onClickLoginHandler }
> 로그인
</ button >
</ div >
</ div >
< div onClick = { () => navigate ( '/signup' ) } > 이메일로 회원가입 </ div >
</ div >
</ div >
</ div >
</>
);
}
export default Login ;
개발할 때 어떤 걸 우선적으로 할지 생각하기!
(어떤 데이터가 필요한지, 그러면 데이터를 먼저 담을지 등등...)
오늘도 고생 많았다.