이전글
https://greed-yb.tistory.com/304
이전글 security 를 참고하여 만들었다
https://greed-yb.tistory.com/288
참고만 하였을 뿐 많은 부분이 바뀌었으며
교육이나 책을 보고 하는 게 아니라
대부분 구글링 또는 간간이 ChatGPT 이용으로 공부 위주의 프로젝트를 만들고 있다
개발에 정답은 없지만 현재 방법은 개념이 맞지 않거나 코드가 부실할 수도 있으니 주의 바란다
JWT 추가하기
// gradle
// security-jwt
implementation group: 'org.springframework.security', name: 'spring-security-jwt', version: '1.1.1.RELEASE'
// JWT
implementation group: 'com.auth0', name: 'java-jwt', version: '4.2.1'
WebSecurityConfig(Security 설정 클래스)
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;
@EnableWebSecurity
@Configuration
public class WebSecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf().disable()
.authorizeHttpRequests(auth -> auth
.requestMatchers("/resources/**", "/static/**", "/css/**", "/js/**").permitAll() // 정적 리소스 허용
.requestMatchers("/api/**").permitAll() //axios 허용
.anyRequest().authenticated() // 나머지 요청은 인증 필요
);
return http.build();
}
}
인증/인가 코드를 제외하였다
WebConfig(Cors 적용을 위한 클래스)
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfig implements WebMvcConfigurer {
public void addCorsMappings(CorsRegistry corsRegistry){
corsRegistry.addMapping("/api/**")
.allowedOrigins("http://localhost:3000") // React 앱의 URL
.allowedMethods("*")
.allowCredentials(true);
}
}
JwTokenUtil
import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.interfaces.DecodedJWT;
import org.springframework.stereotype.Component;
import java.util.Date;
@Component
public class JwtTokenUtil {
private static final String SECRET_KEY = "jwttoken_secret_key";
private static final String REFRESH_SECRET_KEY = "jwttoken_refresh_secret_key";
// private static final long ACCESS_TOKEN_EXPIRATION = 60 * 60 * 1000; // 60분
// private static final long REFRESH_TOKEN_EXPIRATION = 7 * 24 * 60 * 60 * 1000; // 7일
// TEST 용
private static final long ACCESS_TOKEN_EXPIRATION = 1 * 60 * 1000; // 1분
private static final long REFRESH_TOKEN_EXPIRATION = 5 * 60 * 1000; // 5분
// Access Token 생성
public String generateAccessToken(String username) {
return JWT.create()
.withSubject(username)
.withExpiresAt(new Date(System.currentTimeMillis() + ACCESS_TOKEN_EXPIRATION))
.sign(Algorithm.HMAC512(SECRET_KEY.getBytes()));
}
// Refresh Token 생성
public String generateRefreshToken(String username) {
return JWT.create()
.withSubject(username)
.withExpiresAt(new Date(System.currentTimeMillis() + REFRESH_TOKEN_EXPIRATION))
.sign(Algorithm.HMAC512(REFRESH_SECRET_KEY.getBytes()));
}
// 검증 된 Access Token 에서 username 추출
public String getUsernameFromAccessToken(String token) {
DecodedJWT jwt = JWT.require(Algorithm.HMAC512(SECRET_KEY.getBytes()))
.build()
.verify(token);
return jwt.getSubject();
}
// 검증 된 Refresh Token 에서 username 추출
public String getUsernameFromRefreshToken(String token) {
DecodedJWT jwt = JWT.require(Algorithm.HMAC512(REFRESH_SECRET_KEY.getBytes()))
.build()
.verify(token);
return jwt.getSubject();
}
// 유효한 Access Token 인지 검증
public boolean validateAccessToken(String token) {
try {
JWT.require(Algorithm.HMAC512(SECRET_KEY.getBytes()))
.build()
.verify(token);
return true;
} catch (Exception e) {
return false;
}
}
// 유효한 Refresh Token 인지 검증
public boolean validateRefreshToken(String token) {
try {
JWT.require(Algorithm.HMAC512(REFRESH_SECRET_KEY.getBytes()))
.build()
.verify(token);
return true;
} catch (Exception e) {
return false;
}
}
}
TokenVo
import lombok.Data;
@Data
public class TokenVo {
private String accessToken;
private String refreshToken;
}
굳이 vo 만들 필요 없이 받아도 된다
UserVo
import lombok.Data;
@Data
public class UserVo {
private String username;
private String password;
}
로그인을 위한 TEST 용
굳이 vo 만들 필요 없이 받아도 된다
LoginController
import com.example.reactpractice.security.JwtTokenUtil;
import com.example.reactpractice.vo.tokenVo.TokenVo;
import com.example.reactpractice.vo.userVo.UserVo;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.Map;
@RestController
@RequestMapping("/api/*")
public class LoginController {
private final JwtTokenUtil jwtTokenUtil;
// jwtTokenUtil 에 설정한 시간과 동일한 시간으로 맞춘다
private int accessTime = 1 * 60; // 1분
private int refreshTime = 5 * 60; // 5분
public LoginController(JwtTokenUtil jwtTokenUtil) {
this.jwtTokenUtil = jwtTokenUtil;
}
@PostMapping("/login")
public ResponseEntity<Map<String, Object>> loginCheck(@RequestBody UserVo vo, HttpServletResponse response) {
// 반환할 데이터 구성
Map<String, Object> data = new HashMap<>();
try {
/////////////////////////////////////////////////////////////////////
// 세션 기반 인증 및 특정 로직일때 사용함 , 혹시 필요할까 싶어서 작성, 지워도 무방하다
// 사용자 인증
Authentication authentication = new UsernamePasswordAuthenticationToken(vo.getUsername(), vo.getPassword());
// 사용자 정보 set
SecurityContextHolder.getContext().setAuthentication(authentication);
/////////////////////////////////////////////////////////////////////
// 사용자가 입력한 비밀번호와 DB에 암호화 되어 있는 사용자의 비밀번호를 비교하기 위함
PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
// test 용으로 사용자의 비밀번호가 1111 이라고 가정
String test = passwordEncoder.encode("1111");
// 사용자가 입력한 비밀번호와 DB의 비밀번호가 일치하지 않으면 login 페이지로 이동
if (vo.getPassword() == null || !passwordEncoder.matches(vo.getPassword(), test)) {
// 인증 실패 시 다시 로그인 페이지로
data.put("message", "fail");
return ResponseEntity.ok(data);
}
// JWT 토큰 생성
String accessToken = jwtTokenUtil.generateAccessToken(vo.getUsername());
String refreshToken = jwtTokenUtil.generateRefreshToken(vo.getUsername());
// 쿠키에 JWT 저장
Cookie accessTokenCookie = new Cookie("accessToken", accessToken);
accessTokenCookie.setHttpOnly(true);
accessTokenCookie.setDomain("localhost"); // localhost에 쿠키 설정
accessTokenCookie.setPath("/");
accessTokenCookie.setMaxAge(accessTime);
accessTokenCookie.setHttpOnly(false); // javascript 로 cookie 접근 허용(false)
response.addCookie(accessTokenCookie);
Cookie refreshTokenCookie = new Cookie("refreshToken", refreshToken);
refreshTokenCookie.setHttpOnly(true);
refreshTokenCookie.setDomain("localhost"); // localhost에 쿠키 설정
refreshTokenCookie.setPath("/");
refreshTokenCookie.setMaxAge(refreshTime);
refreshTokenCookie.setHttpOnly(false); // javascript 로 cookie 접근 허용(false)
response.addCookie(refreshTokenCookie);
data.put("accessToken", accessToken);
data.put("refreshToken", refreshToken);
data.put("role", "guest"); // test 용 임의 권한 지정
data.put("message", "success");
}catch (Exception e){
e.printStackTrace();
}
return ResponseEntity.ok(data);
}
@PostMapping("/newAccessToken")
public ResponseEntity<Map<String, Object>> newAccessToken(HttpServletResponse response, @RequestBody TokenVo vo) throws Exception{
// vo 사용하지 않을 때
// public ResponseEntity<Map<String, Object>> newAccessToken(HttpServletResponse response, @RequestParam String refreshToken) throws Exception{
// 반환할 데이터 구성
Map<String, Object> data = new HashMap<>();
// 유효한 refreshToken 인지 확인
if(jwtTokenUtil.validateRefreshToken(vo.getRefreshToken())){
// 유저정보 가져와서 새로운 accessToken 발행
String username = jwtTokenUtil.getUsernameFromRefreshToken(vo.getRefreshToken());
String newAccess = jwtTokenUtil.generateAccessToken(username);
// 쿠키에 JWT 저장
Cookie accessTokenCookie = new Cookie("accessToken", newAccess);
accessTokenCookie.setHttpOnly(true);
accessTokenCookie.setDomain("localhost"); // localhost에 쿠키 설정
accessTokenCookie.setPath("/");
accessTokenCookie.setMaxAge(accessTime);
accessTokenCookie.setHttpOnly(false); // javascript 로 cookie 접근 허용(false)
response.addCookie(accessTokenCookie);
data.put("message", "success");
return ResponseEntity.ok(data);
}else{
data.put("message", "fail");
return ResponseEntity.ok(data);
}
}
}
/login 메서드에서
PasswordEncoder 을 이용한 encode 와 matches 를 사용한 부분은
사용자가 입력한 ID 와 Password 를 가지고 DB 의 데이터 조회하여 일치하는지 확인하는 부분이다
회원가입 페이지 생성 및 JPA 를 다룰 때 변경할 예정이다
auth(token 인증)
import axios from "axios";
// 쿠키에서 token 가져오기
function getCookie(cookieName: string) {
const cookieString = document.cookie;
const cookies = cookieString.split('; '); // '; '로 accessToken 과 refreshToken 을 구분한다
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i];
const [name, value] = cookie.split('='); // '='로 이름과 값을 분리
if (name === cookieName) {
return value;
}
}
return null;
}
// 유효한 token 인지 검증
export const isTokenCheck = () => {
const token = document.cookie;
// token 유무
if (!token){
return false;
}
try{
const accessToken = getCookie("accessToken");
if(accessToken != null){
// access token 체크
const accessPayload = JSON.parse(atob(accessToken.split('.')[1]));
const accessExpTime = accessPayload.exp * 1000; // 만료 시간 변수
if( Date.now() < accessExpTime ){// 현재 시간과 만료 시간 비교
return true;
}
}
const refreshToken = getCookie("refreshToken");
if(refreshToken != null){
// accessToken 은 만료되었으면 재발급 받아야 함
// refresh token 체크
const refreshPayload = JSON.parse(atob(refreshToken.split('.')[1]));
const refreshExpTime = refreshPayload.exp * 1000; // 만료 시간 변수
if( Date.now() < refreshExpTime ){// 현재 시간과 만료 시간 비교
// body 데이터 형식
axios.post("http://localhost:7172/api/newAccessToken",{refreshToken} ,{withCredentials: true })
.then((res => {
if (res.data.message == "fail"){
return false;
}
}))
// Param 형식으로 데이터 전송 시
// axios.post("http://localhost:7172/api/newAccessToken",null ,
// {
// params: {
// refreshToken: refreshToken
// }
// , withCredentials: true })
// .then((res => {
// if (res.data.message == "fail"){
// return false;
// }
// }))
return true;
}
}
}catch (error){
localStorage.removeItem("jwtToken");
return false;
}
};
데이터 전송 방식은 각자 편한 방식으로 보내면 된다
privateRoute(token 여부에 따른 페이지 이동 router)
import React from 'react';
import { Navigate, RouteProps } from 'react-router-dom';
import {isTokenCheck} from './auth';
interface PrivateRouteProps extends Omit<RouteProps, 'element'> {
children: React.ReactNode; // children으로 설정
}
const PrivateRoute: React.FC<PrivateRouteProps> = ({ children }) => {
return isTokenCheck() ? (
<>{children}</> // 인증되었으면 App.tsx 에서 설정한 경로로 Route 를 한다
) : (
<Navigate to="/signin" replace /> // 인증되지 않았으면 로그인 페이지로
);
};
export default PrivateRoute;
페이지 이동시 마다 token 을 체크한다
login
import React, {useState} from "react";
import axios from "axios";
function Login() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const handleLogin = async (e: { preventDefault: () => void; }) => {
e.preventDefault();
try {
const response = await axios.post('http://localhost:7172/api/login', {
username,
password,
}, {withCredentials: true }); // 쿠키를 자동 입력해 준다
if(response.data.message == "success"){
// 로그인 성공 후 홈 페이지로 이동
window.location.href = '/';
}else {
console.error('Login failed');
}
} catch (error) {
console.error('Login failed:', error);
}
};
return(
<main>
<div className="container">
<section
className="section register min-vh-100 d-flex flex-column align-items-center justify-content-center py-4">
<div className="container">
<div className="row justify-content-center">
<div
className="col-lg-4 col-md-6 d-flex flex-column align-items-center justify-content-center">
<div className="d-flex justify-content-center py-4">
<a href="index.html" className="logo d-flex align-items-center w-auto">
<img src="assets/img/logo.png" alt=""/>
<span className="d-none d-lg-block">NiceAdmin</span>
</a>
</div>
<div className="card mb-3">
<div className="card-body">
<div className="pt-4 pb-2">
<h5 className="card-title text-center pb-0 fs-4">Login to Your Account</h5>
<p className="text-center small">Enter your username & password to login</p>
</div>
<form className="row g-3 needs-validation" noValidate onSubmit={handleLogin}>
<div className="col-12">
<label htmlFor="username" className="form-label">Username</label>
<div className="input-group has-validation">
<span className="input-group-text" id="inputGroupPrepend">@</span>
<input type="text" name="username" className="form-control"
id="username" required onChange={(e) => setUsername(e.target.value) }/>
<div className="invalid-feedback">Please enter your username.
</div>
</div>
</div>
<div className="col-12">
<label htmlFor="password" className="form-label">Password</label>
<input type="password" name="password" className="form-control"
id="password" required onChange={(e) => setPassword(e.target.value)}/>
<div className="invalid-feedback">Please enter your password!</div>
</div>
<div className="col-12">
<div className="form-check">
<input className="form-check-input" type="checkbox" name="remember"
value="true" id="rememberMe"/>
<label className="form-check-label" htmlFor="rememberMe">Remember
me</label>
</div>
</div>
<div className="col-12">
<button className="btn btn-primary w-100" type="submit">Login</button>
</div>
<div className="col-12">
<p className="small mb-0">Don't have account?
<a href="pages-register.html">Create an account</a></p>
</div>
</form>
</div>
</div>
<div className="credits">
Designed by <a href="https://bootstrapmade.com/">BootstrapMade</a>
</div>
</div>
</div>
</div>
</section>
</div>
</main>
);
}
export default Login;
login 페이지는 Bootstrap 의 login 페이지를 가져와
<form> 태그에 onSubmit 과
username, password <input> 태그에 onChange 를 추가하였다
App
import {BrowserRouter, Route, Routes, useLocation} from "react-router-dom";
import Header from "./components/layout/header";
import Footer from "./components/layout/footer";
import Sidebar from "./components/layout/sidebar";
import Dashboard from "./components/pages/dashboard";
import Profile from "./components/pages/profile";
import Login from "./components/pages/login";
import PrivateRoute from "./components/utils/privateRoute";
// 페이지 layout 구분하기
function AppLayout(){
const location = useLocation();
const pageCheck = location.pathname === "/signin";
return (
<div className="App">
{!pageCheck && <Header/>}
{!pageCheck && <Sidebar/>}
<Routes>
<Route path="/signin" element={<Login />}/>
<Route path="/" element={
<PrivateRoute>
<Dashboard />
</PrivateRoute>
}/>
<Route path="/profile" element={
<PrivateRoute>
<Profile />
</PrivateRoute>
}/>
</Routes>
{!pageCheck && <Footer/>}
</div>
);
}
function App() {
return (
<BrowserRouter>
<AppLayout/>
</BrowserRouter>
);
}
export default App;
login 페이지는 header , sidebar , footer 가 나오면 안 되기 때문에
layout 을 분리시켜 준다
login.tsx 를 Router path="/signin" 으로 설정한 이유는
/login 으로 지정하면 SpringSecurity 에서 경로를 못 찾을 수도 있다고 하여 구분을 하였다
TEST
React 와 Spring 을 실행시킨다
localhost:3000 으로 접속되면서 token 이 없기 때문에 로그인 페이지 /signin 으로 이동되었다
username 에 아무 값을 넣고
Password 에 Test 용으로 하드코딩한 1111 을 입력하고 Login 버튼을 클릭한다
token 이 발행되어 localhost:3000 으로 접속되었고
Cookies 에 accessToken 과 refreshToken 이 들어왔다
현재 Test 로 accessToken - 1분 , refreshToken - 5분으로 설정했기 때문에
1분이 지나면 accessToken 은 사라지며 다른 페이지 이동 및 기능을 동작하게 되면
refreshToken 이 유효한지에 따라서 accessToken 을 재발행하거나 login 페이지로 이동하게 된다
1분이 지나서 accessToken 이 사라졌다
Cookies 페이지를 열고 있는 상태라면
다른 탭을 클릭했다가 오면 사라져 있는 것을 볼 수 있다
다른 페이지 이동 및 기능을 동작하게 되면 refreshToken 이 유효한지 체크하여
새로운 accessToken 을 발행하여 Cookies 에 저장한다
다른 페이지 이동 및 기능을 동작하게 되면 token 이 없기 때문에 login 페이지로 이동된다
참고 index.js 또는 index.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
// // StrictMode : 개발 환경에서 컴포넌트의 렌더링이 2번씩 실행된다 - 예) 메소드가 두번씩 실행됨
// // 실제 운영 환경에서는 한번만 실행된다고 함
// root.render(
// <React.StrictMode>
// <App />
// </React.StrictMode>
// );
// StrictMode 사용하지 않을 경우
root.render(<App />);
reportWebVitals();
axios 를 사용해서 데이터를 확인하던 중
동일한 메서드 두 번씩 동작하는 것을 확인하였고
render 할 때 StrictMode 설정 때문이라는 것을 알게 되었다
참고하길 바란다
React 에 대한 기본지식도 없는 상태로
맨땅에 헤딩을 하면서 Security 까지 적용하려고 하니 머리가 터질 것 같다...
지금도 정상적으로 구현한 느낌은 아니다
아마 실제로도 그렇겠지...?
프로젝트를 계속 진행하며 점차 수정을 할 것이고,
React + Spring 을 한 번에 build 하게 되면 다시 변경해야 할 것으로 보인다
현재 cookie.setHttpOnly(false) 를 설정해서
javascript 로 document.cookie 명령어가 동작하고,
이걸로 Token 을 가져오고 있는데 보안적으로 좋아 보이지는 않는다
근데 막상 큰 사이트들 들어가서 확인해 보면 다들 명령어가 동작하는 것으로 봐서는.....음....
'개발 > Spring' 카테고리의 다른 글
[SpringBoot] React + TypeScript + JPA 프로젝트 (5) - 게시판 만들기(목록) (0) | 2024.11.04 |
---|---|
[SpringBoot] React + TypeScript + JPA 프로젝트 (4) - 회원가입, 로그인 하기 (1) | 2024.11.01 |
[SpringBoot] React + TypeScript + JPA 프로젝트 (2) - Bootstrap 적용 (1) | 2024.10.25 |
[SpringBoot] React + TypeScript + JPA 프로젝트 (1) - 생성 (0) | 2024.10.24 |
[SpringBoot] Intellij + React + TypeScript 프로젝트 생성하기 (0) | 2024.10.22 |
댓글