Spring10_JWT 프로젝트 생성


dependency 추가<!-- Json Web Token 관련 라이브러리 -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
패키지 생성
@AllArgsConstructor : 모든 필드값을 전달받는 생성자@Builder
@AllArgsConstructor
@NoArgsConstructor // 빈 생성자
@Data // setter & getter
public class UserDto {
private long num;
private String userName;
private String password;
private String email;
private String role;
private String profileImage;
private String createdAt;
private String updatedAt;
// <input type="file" name="profileImage"> 을 처리하기 위한 필드
private MultipartFile profileFile;
}
@Service : bean 으로 만들기 위한 어노테이션@Service
public class CustomUserDetailsService implements UserDetailsService{
// userName 을 전달하면 해당 user 의 자세한 정보를 리턴하는 메소드
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// 원래는 DB 에서 dao 를 이용해 username 에 해당하는 사용자정보(UserDto) 를 얻어와야 한다.
// 지금은 sample 데이터를 만들어서 사용하기
// 실제 DB 에는 ROLE_XXX 형식으로 저장이 되어 있어야 한다
String role = "";
if(username.equals("kimgura")) {
role = "ROLE_USER";
}else if(username.equals("batman")) {
role = "ROLE_STAFF";
}else if(username.equals("superman")){
role = "ROLE_ADMIN";
}
// 비밀번호를 암호화 하기 위한 객체
BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
// DB 에는 암호화된 비밀번호가 저장되어 있다고 가정하자 (암호화 하기 이전의 비밀번호가 1234 라고 가정)
String encodedPwd = encoder.encode("1234");
// Sample UserDto 객체 만들기(원래는 DB 에서 읽어온 데이터)
UserDto dto=UserDto.builder()
.userName(username)
.password(encodedPwd)
.email("aaa@naver.com")
.role(role)
.build();
/*
* Spring Security 에서 role 과 authority 는 약간의 차이가 있지만 편의상 같다라고 생각하자
*
* ROLE_USER 를 예를 들면
*
* 해당 USER 의 role 은 "USER"
* 해당 USER 의 authority 는 "ROLE_USER" 임을 명심하자...
*
*/
// 권한 목록을 List 에 담아서 (지금은 1개 이지만)
List<GrantedAuthority> authList = new ArrayList<>();
// Role 을 포함한 authoryty 객체를 생성해서 authList 에 추가
authList.add(new SimpleGrantedAuthority(dto.getRole()));
// UserDetails 객체를 생성해서
UserDetails ud = new User(dto.getUserName(), dto.getPassword(), authList);
// 리턴해준다
return ud;
}
}
@Component
public class JwtUtil {
/*
* custom.properties 파일을 만들고 해당 파일에
* jwt.secret 라는 키값으로 토큰을 발급할 때 서명할 key 를 설정했는데 그 문자열을 읽어와야 한다
* */
@Value("${jwt.secret}")
private String secretKey;
// jwt.expiration 라는 키값으로 토큰의 유효기간을 설정했는데 해당 숫자를 읽어와야 한다
@Value("${jwt.expiration}")
private long expiration;
private Key getSigningKey() {
return Keys.hmacShaKeyFor(Base64.getDecoder().decode(secretKey));
}
public String extractUsername(String token) {
return extractClaim(token, Claims::getSubject);
}
public Date extractExpiration(String token) {
return extractClaim(token, Claims::getExpiration);
}
public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
final Claims claims = extractAllClaims(token);
return claimsResolver.apply(claims);
}
public Claims extractAllClaims(String token) {
return Jwts.parserBuilder()
.setSigningKey(getSigningKey()) // token 발급시 서명했던 키값도 일치하는지 확인도 된다.
.build()
.parseClaimsJws(token)
.getBody();
}
private Boolean isTokenExpired(String token) {
return extractExpiration(token).before(new Date());
}
public String generateToken(String username, Map<String, Object> extraClaims) {
Map<String, Object> claims = new HashMap<>(extraClaims);
return createToken(claims, username);
}
private String createToken(Map<String, Object> claims, String subject) {
return Jwts.builder()
.setClaims(claims) // 추가 정보
.setSubject(subject) // 주요 정보(주로 userName)
.setIssuer("your-issuer") // 추가된 issuer(발급한 서비스명)
.setIssuedAt(new Date(System.currentTimeMillis())) // 발급 시간
.setExpiration(new Date(System.currentTimeMillis() + expiration)) // 파기되는 시간
.signWith(getSigningKey(), SignatureAlgorithm.HS256) // HS256 알고리즘으로 서명
.compact();
}
// token 검증하는 메소드
public boolean validateToken(String token) {
// token 에 담긴 추가 정보를 얻어낼수 있다 (role, issuer등등)
Claims claims = extractAllClaims(token);
// 토큰 유효기간이 남아 있는지와 issuer 정보도 일치하는지 확인해서
boolean isValid=!isTokenExpired(token) && "your-issuer".equals(claims.getIssuer());
// 유효성 여부를 리턴한다
return isValid;
}
}
➜ JwtUtil 클래스가 동작하기 위해서 pom.xml 에 아래의 dependency 를 추가 해야 한다
<!-- Json Web Token 관련 라이브러리 -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
# jwt token secret key
jwt.secret = mySecretKey000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111
# Jwt token expiration mili second
jwt.expiration = 1800000
@PropertySource 어노테이션 추가@PropertySource(value = "classpath:custom.properties")
@Component
public class JwtFilter extends OncePerRequestFilter{
@Autowired JwtUtil jwtUtil;
@Autowired CustomUserDetailsService service;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
String jwtToken="";
// 요청의 Header 에 "Authorization" 이라는 키값으로 전달된 문자열이 있는지 읽어와 본다.
String authHeader = request.getHeader("Authorization");
if(authHeader != null && authHeader.startsWith("Bearer ")) {
jwtToken = URLDecoder.decode(authHeader, StandardCharsets.UTF_8);
}
// 토큰이 있으면 토큰에서 userName 을 얻어낸다
String userName = null;
if(jwtToken.startsWith("Bearer ")) {
// "Bearer " 를 제외한 뒤의 token 문자열을 얻어낸다.
jwtToken = jwtToken.substring(7);
// userName 을 token 으로 부터 얻어낸다.
userName = jwtUtil.extractUsername(jwtToken);
}
// userName 이 존재하고 Spring Security 에서 아직 인증을 받지 않은 상태라면
if(userName != null && SecurityContextHolder.getContext().getAuthentication() == null) {
// 토큰이 유효한 토큰인지 체크한다음
boolean isValid = jwtUtil.validateToken(jwtToken);
// 유효하다면 1회성 로그인(spring security 를 통과할 로그인) 을 시켜준다.
if(isValid) {
Claims claims = jwtUtil.extractAllClaims(jwtToken); // JWT에서 모든 정보 가져오기
String role = claims.get("role", String.class);
// userName 과 role 정보를 담은 UserDetails 객체를 만든다.
UserDetails ud = User.withUsername(userName)
.password("") //비밀번호는 필요없지만 null 인 상태면 builder 에서 에러발생
.authorities(role)
.build();
// 사용자가 제출한 사용자 이름과 비밀번호와 같은 인증 자격 증명을 저장
UsernamePasswordAuthenticationToken authToken=
new UsernamePasswordAuthenticationToken(ud, null,
ud.getAuthorities());
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
// Security 컨텍스트 업데이트 (1회성 로그인)
SecurityContextHolder.getContext().setAuthentication(authToken);
}
}
// 다음 spring 필터 chain 진행하기
filterChain.doFilter(request, response);
}
}
@Configuration : 설정 클래스라고 알려준다@EnableWebSecurity : Security 를 설정하기 위한 어노테이션@EnableMethodSecurity(securedEnabled = true)@Configuration
@EnableWebSecurity
@EnableMethodSecurity(securedEnabled = true)
public class SecurityConfig {
@Autowired
private JwtFilter jwtFilter;
/*
* 매개변수에 전달되는 HttpSecurity 객체를 이용해서 우리의 프로젝트 상황에 맞는 설정을 기반으로
* 만들어진 SecurityFilterChain 객체를 리턴해주어야 한다
* 또한 SecurityFilterChain 객체도 스프링이 관리하는 Bean 이 되어야 한다
*/
@Bean // 메소드에서 리턴되는 SecurityFilterChain 을 bean 으로 만들어준다
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception{
String[] whiteList = {"/auth"};
httpSecurity
.headers(header->
// 동일한 origin 에서 iframe 을 사용할수 있도록 설정(default 값은 사용불가)
header.frameOptions(option->option.sameOrigin()) // SmartEditor 에서 필요함
)
.csrf(csrf->csrf.disable())
.authorizeHttpRequests(config ->
config
.requestMatchers(whiteList).permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN")
.requestMatchers("/staff/**").hasAnyRole("ADMIN", "STAFF")
.requestMatchers(HttpMethod.POST, "/user").permitAll() // api 회원가입 요청은 받아들이도록
.anyRequest().authenticated()
)
.sessionManagement(config ->
// 세션을 사용하지 않도록 설정한다
config.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
// JwtFilter 를 Spring Security 필터보다 미리 수행되게 하기
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
// 설정 정보를 가지고 있는 HttpSecurity 객체의 build() 메소드를 호출해서 리턴되는 객체를 리턴해준다
return httpSecurity.build();
}
//비밀번호를 암호화 해주는 객체를 bean 으로 만든다.
@Bean
PasswordEncoder passwordEncoder() {
//여기서 리턴해주는 객체도 bean 으로 된다.
return new BCryptPasswordEncoder();
}
//인증 메니저 객체를 bean 으로 만든다. (Spring Security 가 자동 로그인 처리할때도 사용되는 객체)
@Bean
AuthenticationManager authenticationManager(HttpSecurity http,
BCryptPasswordEncoder encoder, UserDetailsService service) throws Exception{
// 적절한 설정을한 인증 메니저 객체를 리턴해주면 bean 이 되어서 Spring Security 가 사용한다
return http.getSharedObject(AuthenticationManagerBuilder.class)
.userDetailsService(service)
.passwordEncoder(encoder)
.and()
.build();
}
}
@RequestMapping("/v1")
@RestController
public class UserController {
@Autowired JwtUtil jwtUtil;
// SecurityConfig 클래스에서 Bean 이된 AuthenticationManager 객체 주입 받기
@Autowired AuthenticationManager authManager;
@PostMapping("/login")
public ResponseEntity<String> login(@RequestBody UserDto dto){
Authentication authentication=null;
try {
UsernamePasswordAuthenticationToken authToken=
new UsernamePasswordAuthenticationToken(dto.getUserName(), dto.getPassword());
// 인증 메니저 객체를 이용해서 인증을 진행한다.
authentication=authManager.authenticate(authToken);
}catch(Exception e) {
// 예외가 발생하면 인증실패(아이디 혹은 비밀번호 틀림 등)
e.printStackTrace();
// 401 UNAUTHORIZED 에러를 응답하면서 문자열 한줄 보내기
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("인증 실패!");
}
// Authentication 객체에는 인증된 사용자 정보가 들어 있다 (userName, role 등)
// 현재는 role 을 하나만 부여하기 때문에 0 번 방에 있는 데이터만 불러오면 된다
GrantedAuthority authority=authentication.getAuthorities().stream().toList().get(0);
// ROLE_XXX 형식
String role=authority.getAuthority();
// "role" 이라는 키값으로 Map 에 담기
Map<String, Object> claims=Map.of("role", role);
// 예외가 발생하지 않고 여기까지 실행 된다면 인증을 통과 한 것이다 토큰을 발급해서 응답한다
String token=jwtUtil.generateToken(dto.getUserName(), claims);
// 발급받은 토큰 문자열을 ResponseEntity 에 담아서 리턴한다
return ResponseEntity.ok("Bearer "+token);
}
}
dependency 추가<!-- swagger ui 를 사용하기 위한 dependency (spring boot 3.0 이상 버전에서)-->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.6.0</version>
</dependency>
server.port=9000
String[] whiteList = {"/auth", "/swagger-ui/**", "/v3/api-docs/**", "/v1/login"};
Swagger UI 로 가서 테스트 하기
{
"userName": "kimgura",
"password": "1234"
}

➜ Jwt 토큰의 Secret key 를 환경변수에 담아 읽어오는 구조로 사용한다
ping 메소드 생성@GetMapping("/ping")
public String ping() {
return "pong";
}


localStorage.token = res.data;

<button onClick={()=>{
axios.get("/api/v1/ping", {
headers:{
Authorization:localStorage.token
}
})
.then(res => {
alert(res.data)
})
.catch(err => {
alert("토큰이 없어서 응답 불가")
})
}}>요청 보내기</button>
handleLogout 함수에 토큰 삭제 코트 수정elete localStorage.token;
로그인 없이 클릭 시

로그인 성공 후 클릭 시

src 폴더를 Step05Redux 로 폴더명을 수정 후 src_spa 폴더를 복사 붙여넣기 해 src 로 폴더 생성
iniState & handlers & reducer & store 추가// redux store 에서 관리될 state 의 초기값
const initState = {
userInfo:null,
loginModal:{
title:null,
show:false
}
};
// reducer 함수에서 사용할 handler object
const handlers = {
LOGIN_MODAL : (state, action) => ({
...state,
loginModal:action.payload
}),
USER_INFO : (state, action) => ({
...state,
userInfo:action.payload
})
};
// handler obejct 를 사용하는 새로운 reducer 함수
const reducer = (state = initState, action) => {
// 전달된 action 을 수행할 handler 함수를 얻어낸다
const handler = handlers[action.type];
// handler 함수가 존재한다면 handler 함수가 리턴하는 state 를 리턴하고 아니면
// 원래 state 를 리턴한다
return handler ? handler(state, action) : state;
};
// 위에서 만든 reducer 함수를 사용하는 redux store (중앙 저장소) 만들기
const store = createStore(reducer);
RouterProvider 를 Provider 로 감싸기<Provider store={store}>
<RouterProvider router={router}/>
</Provider>
import axios from "axios";
// baseURL 값으로 "/api" 를 기본으로 가지고 있는 axios 객체를 만들어서
const api = axios.create({
baseURL:"/api"
});
// 요청 인터셉터 (ex : 토큰 자동 추가)
api.interceptors.request.use((config) => {
// const token = localStorage.token; 과 동일한 동작
const token = localStorage.getItem('token');
// 만일 token 이 존재한다면
if (token) {
// 요청의 header 에 Authorization 이라는 키값으로 token 을 전달하도록 한다
config.headers.Authorization = token;
}
return config;
});
// 리턴해준다
export default api;
import axios form 'axios';
axios.post("/api/v1/login", {
headers : {Authorization : xxx}
})
vs
import api from './api';
api.post("/v1/login")
function LoginModal() {
// store 로부터 loginModal 의 상태값을 읽어온다
// {title:"Modal의 제목", show:Modal을 띄울지 여부(true or false)} 형식의 object
const loginModal = useSelector(state => state.loginModal);
// 입력한 userName 과 password 를 상태값으로 관리
const [state, setState] = useState();
// 아이디 비밀번호를 입력했을 때 호출될 함수
const handleChange = (e) => {
// 입력한 내용을 상태값에 반영한다
setState({
...state,
[e.target.name]:e.target.value
});
}
// 에러 메세지를 상태값으로 관리
const [errorMsg, setErrorMsg] = useState(null);
// 액션을 발행할 함수
const dispatch = useDispatch();
// route 이동을 위한 navigate 함수
const navigate = useNavigate();
// 로그인 버튼을 눌렀을 때 실행할 함수
const handleLogin = async () => { // 이 함수는 즉시 리턴하는 함수가 아니라고 async 로 알려준다
try{
// api 서버에 로그인 요청을 보내고
const res = await api.post("/v1/login", state);
// userName 과 password 가 유효한 정보이면 정상 응답이 되고
console.log(res.data);
localStorage.token = res.data;
// redux store 에 로그인 정보를 저장한다
const payload = {userName:state.userName, role:"ADMIN"};
const action = {type:"USER_INFO", payload};
dispatch(action);
// 로그인 폼을 초기화 하고 숨긴다
setErrorMsg(null);
dispatch({
type:"LOGIN_MODAL",
payload:{
title:"",
show:false
}
});
// 로그인 성공 후에 이동할 url
const url = loginModal.url; // null 일 가능성 존재
// 원래 가려던 목적지 정보가 있으면 해당 위치로 이동시킨다
if(url){
navigate(url);
}
}catch(err){
// userName 과 password 가 틀리면 401 UNAUTHORIZED 에러 응답을 할 예정
setErrorMsg(err.response.data);
}
}
// Modal 창을 닫기 버튼을 누르면 react bootstrap Modal 에는 hide 이벤트가 발생
// 해당 이벤트가 발생했을 때 redux store 의 상태값을 변경해서 모달창이 숨겨지도록 한다
const handleHide = () => {
dispatch({
type:"LOGIN_MODAL",
payload:{title:"", show:false}
});
};
return <>
{/* store 에서 관리되는 loginModal 의 show 값을 이용해서 띄울지 말지를 결정 */}
<Modal onHide={handleHide} show={loginModal.show} size="lg" centered>
<Modal.Header closeButton>
<Modal.Title>{loginModal.title}</Modal.Title>
</Modal.Header>
<Modal.Body>
<FloatingLabel controlId='userName' label="User Name" className='mb-3'>
<Form.Control onChange={handleChange} name="userName" type="text" />
</FloatingLabel>
<FloatingLabel controlId='password' label="Password" className='mb-3'>
<Form.Control onChange={handleChange} name="password" type="password" />
</FloatingLabel>
{errorMsg && <Alert variant="danger">{errorMsg}</Alert>}
</Modal.Body>
<Modal.Footer>
<Button onClick={handleLogin}>로그인</Button>
</Modal.Footer>
</Modal>
</>
}
function BsNavBar() {
return <>
<Navbar fixed="top" expand="md" className="bg-warning">
<Container>
<Navbar.Brand as={NavLink} tp="/">Acorn</Navbar.Brand>
</Container>
</Navbar>
</>
}
BsNavBar & div 요소에 style 추가<BsNavBar/>
<div className="container" style={{marginTop:"60px"}}>
@RequestMapping("/v1")
@RestController
public class HomeController {
@GetMapping("/notice")
public List<String> notice(){
return List.of("React 로 App 구현중 입니다", "테스트 시작", "테스트 완료");
}
}
String[] whiteList = {"/swagger-ui/**", "/v3/api-docs/**", "/v1/notice"};
useState & useEffect 추가 및 공지사항 출력map() 함수에 전달한 함수에는 2번째 매개변수에 item 의 인덱스 값도 전달된다 // 공지사항
const [noticeList, setNoticeList] = useState([]);
useEffect(()=>{
api.get("/v1/notice")
.then(res => setNoticeList(res.data))
.catch(err => console.log(err));
}, []);
return (
<>
<h1>인덱스 페이지</h1>
<h2>공지 사항</h2>
<ul>
{noticeList.map((item, index)=><li key={index}>{item}</li>)}
</ul>
</>
);

Navbar & Nav & 버튼 코드 추가<Navbar.Toggle aria-controls="collapse"/>
<Navbar.Collapse id="collapse">
<Nav className="me-auto">
<Nav.Link as={NavLink} to="/">HOME</Nav.Link>
<Nav.Link as={NavLink} to="/posts">POST</Nav.Link>
<Nav.Link as={NavLink} to="/gallery">Gallery</Nav.Link>
</Nav>
<Button size="sm" variant="success">로그인</Button>
<Button size="sm" variant="primary" className="ms-1">회원가입</Button>
</Navbar.Collapse>
useDispatch 함수 추가const dispatch = useDispatch();
useSelector 함수 추가const userInfo = useSelector(state => state.userInfo);
useNavigate 함수 추가const navigate = useNavigate();
handleLogin 함수 추가 const handleLogin = (e) => {
// LoginModal 을 띄울 action 을 발행한다
const action = {type:"LOGIN_MODAL", payload:{
title:"로그인 폼 입니다",
show:true
}};
dispatch(action);
};
onClick 이벤트 요소 추가<Button onClick={handleLogin} size="sm" variant="success">로그인</Button>
LoginModal 사용import LoginModal from './components/LoginModal';
<LoginModal/>


Nav & 로그아웃 버튼 추가<Nav>
<Nav.Link>{userInfo.userName}</Nav.Link>
<span className="navbar-text">로그인중...</span>
</Nav>
<Button className="ms-1" size="sm" variant="outline-primary">로그아웃</Button>
{ userInfo ?
<>
<Nav>
<Nav.Link>{userInfo.userName}</Nav.Link>
<span className="navbar-text">로그인중...</span>
</Nav>
<Button size="sm" variant="outline-primary">로그아웃</Button>
</>
:
<>
<Button onClick={handleLogin} size="sm" className="ms-1" variant="success">로그인</Button>
<Button size="sm" variant="primary" className="ms-1">회원가입</Button>
</>
}
onClick 이벤트 요소 추가<Button onClick={handleLogout} size="sm" variant="outline-primary">로그아웃</Button>
handleLogout 함수 추가const handleLogout = (e) => {
// 토큰 삭제
delete localStorage.token;
// redux store 에 userInfo 초기화
dispatch({type:"USER_INFO", payload:null});
// 인덱스로 이동
navigate("/");
}

jwt-decode 설치
npm install jwt-decode
useEffect 함수 추가useEffect(()=>{
const token = localStorage.token;
if(token){
// 앞의 7 자리 문자열 "Bearer" 를 제외한 위의 문자열을 디코딩한다
const decoded = jwtDecode(token.substring(7));
// 콘솔에 출력
console.log("토큰 정보");
console.log(decoded);
}
}, []);

useDispatch 함수 추가const dispatch = useDispatch();
useEffect 함수 안에 api 코드 추가api.get("/v1/ping")
.then(res => {
// 여기가 실행되면 사용 가능한 토큰
// 발행할 action
const action = {type:"USER_INFO", payload:{
userName : decoded.sub,
role:decoded.role
}};
//액션 발행하기
dispatch(action);
})
.catch(err => {
// 여기가 실행되면 사용 불가능한 토큰
delete localStorage.token;
});
➜ 새로고침을 해도 토큰이 남아있으면 로그인된 상태로 뜬다
