행복을 담는 블로그
[Spring Boot / Vue] 로그인 JWT 인증/인가 기능 구현 본문
JWT 인증(Authentication) / 인가(Authorization)
인증(Autentication)
: “신원을 확인하는 과정”- JWT 토큰이 유효한지 확인하는 과정
- User DB에서 이 사람이 회원가입 한 사람이 맞는지 확인하는 과정
인가(Autentication)
: “접근을 허가 또는 거절하는 과정”- SpringSecurity Context의 Autentication 객체를 확인하여 접근을 허가할지 말지 선택하는 과정
- 지금 들어오는 요청을 이 사람이 해도 되는지 확인하는 과정
- ex) userId=4인 사람이 userId=5인 사람 페이지 가서 todo를 등록할 수 없게 하기
출처 [Spring Security / JWT] Spring Security - JWT 토큰 인증/인가
발행되는 Token의 타입
Bearer 토큰
- OAuth 2.0에서 가장 많이 사용되는 인증 방식
- 서버에서 클라이언트가 접근 권한을 받으면, Bearer 토큰을 발급하고, 클라이언트는 이를 HTTP 요청 헤더에 포함해 자원 서버에 요청을 보낸다.
- Bearer 토큰 자체가 인증 자격을 의미한다. 즉, 토큰을 가진 사람이 해당 자원에 접근할 수 있게 된다.
- BUT! 토큰을 탈취 당했을 때, 제 3자가 토큰을 사용하여 인증을 수행할 수 있기에 보안에 취약
- 이를 방지하기 위해 https를 통해 안전하게 전송해야함
MAC 토큰
- 메세지 인증 코드 방식으로 Bearer 토큰보다 높은 보안을 제공
- 요청 시 마다, MAC 토큰과 함께 서명(Signature)를 생성해 서버로 전송하며, 서명에는 토큰뿐만아니라 http 메서드, url 요청 타임 스탬프 등이 포함된다.
- MAC 토큰은 요청마다 서명을 추가하기 때문에 중간에서 요청을 가로채더라도 제3자가 서명을 재생성하지 않는 한 요청을 변조할 수 없어 보안이 더 뛰어남
정리
- Bearer 토큰은 간편하지만 탈취 위험이 있으므로 HTTPS와 함께 사용하는 것이 중요
- MAC 토큰은 Bearer에 비해 보안이 강화된 방식으로, 요청마다 서명 검증을 통해 위조를 방지
JWT 토큰 인증/인가 Spring Boot로 구현하기
0. pom.xml 의존성 주입하기
JWT 토큰을 사용하기 위한 의존성을 주입한다.
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.6</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
1. TokenInfo DTO 만들기
클라이언트에서 토큰을 보내기 위한 DTO 생성
/* TokenInfo */
public class TokenInfo {
private String grantType; // 발행되는 토큰의 타입
private String accessToken; // access-token
private String refreshToken; // refresh-token
}
- grantType은 JWT 인증 타입으로, 반환되는 토큰의 타입을 작성한다.
- 주로 Bearer과 MAC이 존재한다.
2. JwtUtil
: JWT 토큰 생성, 토큰 암호화 및 정보 추출, 토큰 유효성 검사 등의 기능이 구현된 클래스
1) JWT_SECRET_KEY 만들기
토큰의 암호화/ 복호화를 위한 secret key를 만들어야 한다.
- .env 파일
/* .env */
JWT_SECRET_KEY="여기에 시크릿키 작성" // " "는 작성할 필요 X
- application.properties
/* application.properties */
spring.config.import=optional:file:.env[.properties] // env에 있는 모든 속성을 import
JWT_SECRET_KEY=${JWT_SECRET_KEY} // env 파일의 JWT_SECRET_KEY 불러오기
- import만 해오면 env 파일 내의 정보를 모두 읽어 올 것이라고 생각했으나,
JWT_SECRET_KEY=${JWT_SECRET_KEY}
이 부분을 작성해주어 그 값을 읽어와서JWT_SECRET_KEY
이 값으로 사용할 수 있었다. - 이
JWT_SECRET_KEY
값을 활용하여 암호화 알고리즘을 사용하여 암호화를 진행한다.- 256비트보다 커야한다.
- 알파벳은 한 단어 당 8bit이므로 전체가 32글자 이상이어야 한다.
- JwtUtil
/* JwtUtil */
@Component
public class JwtUtil {
/* 토큰 SECRET KEY */
@Value("${JWT_SECRET_KEY}")
private String JWT_SECRET_KEY;
private SecretKey secretKey;
// @PostConstruct로 secretKey 초기화
// Spring Bean의 초기화 후, 딱 한 번만 실행되는 메서드를 지정할 때 사용
@PostConstruct
private void initSecretKey( ) {
this.secretKey = Keys.hmacShaKeyFor(JWT_SECRET_KEY.getBytes(StandardCharsets.UTF_8));
}
}
JwtUtil을
@Component
어노테이션을 이용하여 빈으로 등록한다.@Value
어노테이션을 사용하여 application.properties의JWT_SECRET_KEY
를 가져온다.@PostConstruct
어노테이션을 사용: Spring에서 Bean이 생성된 후, 초기화 단계에서 실행될 메서드를 지정할 때 사용한다.
@PostConstruct로 표시된 메서드는 객체가 생성되고 의존성 주입이 완료된 후 자동으로 한 번만 호출된다.
JWT_SECRET_KEY
를 기반으로 초기화가 필요한secretKey
변수를 설정하기 위해서 사용함이 초기화 작업은
JWT_SECRET_KEY
값이 주입된 후에만 수행할 수 있기 때문에, Spring의@PostConstruct
를 통해secretKey
가 한 번만 설정되도록 한 것
- 초기화 순서 보장 : JWT_SECRET_KEY가 주입된 후 secretKey를 생성하도록 순서를 보장
- 초기화 작업의 단일성 보장 : secretKey 초기화가 Bean 생성 시 한 번만 수행하도록하여, 불필요한 재설정 방지와 효율적인 리소소 관리가 가능하다.
<br><br>
하지만 여기서 문제가 발생했다...!
❗❗❗ SignatureAlgorithm.HS256 is deprecated!!!
이는 원시 문자열과 Base64로 인코딩된 문자열 사이 혼란이 있어서 이를 방지하고자 jjwt 0.10.0버전부터 사라졌다고 한다.
새로 암호키를 생성하는 방법은 다음과 같다.
SecretKey secretKey = Keys.hamacShaKeyFor(Decoders.BASE64.decode(base64EncodedSecretKey))
따라서 다음과 같은 방법으로 수정하였다.
@PostConstruct
private void initSecretKey( ) {
byte[] keyBytes = Decoders.BASE64.decode(JWT_SECRET_KEY); // deprecated 메서드 새로 작성 방법
this.secretKey = Keys.hmacShaKeyFor(keyBytes);
}
2) 토큰 생성하기
/* JwtUtil */
// 2. 토큰을 생성하는 메서드
// access-token 생성
public String createAccessToken(User user) {
String nickname = user.getUserNickname();
int userId = user.getUserId();
// 토큰 유효기간 설정
Date exp = new Date(System.currentTimeMillis() + 1000*60*60); // 1시간
return Jwts.builder()
.header().add("type", "JWT").and()
.claim("nickname", nickname).claim("userId", userId).expiration(exp)
.signWith(secretKey).compact();
}
// refresh-token 생성
public String createRefreshToken() {
Date exp = new Date(System.currentTimeMillis() + 1000 * 60 * 60 * 24 * 14); // 2주일
return Jwts.builder()
.header().add("type", "JWT").and()
.expiration(exp)
.signWith(secretKey).compact();
}
// 생성한 access-token과 refresh-token을 TokenInfo에 넣기
public TokenInfo createToken (User user) {
String accessToken = createAccessToken(user);
String refreshToken = createRefreshToken();
TokenInfo tokenInfo = new TokenInfo();
tokenInfo.setGrantType("Bearer");
tokenInfo.setAccessToken(accessToken);
tokenInfo.setRefreshToken(refreshToken);
return tokenInfo;
}
이때 생성된 createToken은 AuthService에서 login 시 불러와 사용한다.
3. 로그인 인증(Authentication) 과정
- 로그인 시 입력 받은 아이디(userEmail)와 비밀번호(userPassword)로 등록된 User를 찾는다.
- User의 특정 정보로 JWT 토큰을 생성한다. (accessToken)
- 클라이언트에서 응답갑으로 JWT accessToken을 반환한다.
- 클라이언트는 accessToken을 보관하며, 모든 요청의 HttpHeader에 포함시킨다.
- Vue에서 accessToken 받아서 처리하기
- sessionStorage에 accessToken 저장하기
1) AuthController
/* AuthController */
@RestController
@RequestMapping("/auth")
@Tag(name = "Auth API", description = "로그인 인증/인가 ")
public class AuthController {
private AuthService authService;
public AuthController(AuthService authService) {
this.authService = authService;
}
@PostMapping("/login")
@Operation(summary = "사용자 로그인 ", description = "로그인을 합니다.")
public ResponseEntity<?> logIn(@RequestBody User user) {
try {
TokenInfo tokenInfo = authService.login(user);
return new ResponseEntity<>(tokenInfo, HttpStatus.OK);
} catch (IllegalArgumentException e) {
return new ResponseEntity<>(e.getMessage(), HttpStatus.UNAUTHORIZED);
}
}
}
사용자의 입력을 @RequestBody로 User에 넣어서 이를 바탕으로 user login 메서드를 실행한다.
2) AuthService
/* AuthService */
@Service
public class AuthService {
private UserService userService;
private JwtUtil jwtUtil;
private PasswordEncoder passwordEncoder;
public AuthService(UserService userService, JwtUtil jwtUtil, PasswordEncoder passwordEncoder) {
this.userService = userService;
this.jwtUtil = jwtUtil;
this.passwordEncoder = passwordEncoder;
}
public TokenInfo login(User user) {
User loginUser = null;
if (user.getUserEmail() == null || user.getUserPassword() == null)
throw new IllegalArgumentException("로그인 입력 정보 없음");
// 입력된 이메일 날림 => user 정보 받아오기
User getUser = userService.searchByEmail(user.getUserEmail());
System.out.println(getUser);
// 아이디 또는 비밀번호 없음
String encodePw = getUser.getUserPassword();
if (getUser == null || !passwordEncoder.matches(user.getUserPassword(), encodePw)) { // user가 입력한 pw와 db의 암호화된
throw new IllegalArgumentException("아이디 또는 비밀번호 틀림");
}
// 로그인 성공
loginUser = getUser;
// 회원가입 정보가 맞을 경우, 로그인 요청 시 => JWT 발급
// 여기서 토큰을 발급하여 token을 login의 반환값으로 return 한다.
return jwtUtil.createToken(loginUser);
}
}
이렇게 요청을 날리면,,,
이렇게 성공 요청과 함께 token이 생성된다.
이렇게하면 인증을 위한 토큰 생성을 할 수 있고, 이제 이 토큰이 유효한지, 이 토큰으로 들어오는 요청의 접근을 허용할지 말지를 결정하는 인가 과정을 거쳐야한다.
4. 로그인 인가(Authorization)
Spring Security
- 메서드의 이름을 filterChain으로 설정하면, Spring이 해당 빈을 자동으로 인식하고 처리한다.
- @Configuration, @EnableWebSecurity 어노테이션 설정 : 보안 설정 활성화
- Spring Security의 대부분 설정을 HttpSecurity로 이루어진다.
- URL 접근 권한 설정 인증
- 로그아웃 페이지 인증 성공 및 실패 시 페이지 이동
- csrf 보호, https 강제 호출 등…
인가 처리
: Filter
를 통해 token이 유효한지 아닌지 확인 →유효하다면, Dispatcher Servlet을 통과 → 해당 token을 가진 user가 접근할 수 있는 페이지 권한 설정하기 : 인터셉터
로 진행
Filter
: 토큰이 유효한지 아닌지, 디스패쳐 서블릿을 통과해도 되는지 판단하고 걸러주기Interceptor
: 유효한 사람임은 확인함. 이제 이 사람이 이 페이지로 들어와도 되는지 아닌지 판단하고, 접근하면 안 되는 경우 제한 걸기
flow는 다음과 같이 움직인다.
- 클라이언트에서 로그인한다.
- 서버는 클라이언트에게 Access Token과 Refresh Token을 발급한다. 동시에 Refresh Token은 서버에 저장된다.
- 클라이언트는 local 저장소에 두 Token을 저장한다.
- 매 요청마다 Access Token을 헤더에 담아서 요청한다.
- 이 때, Access Token이 만료가 되면 서버는 만료되었다는 Response를 하게 된다.
- 클라이언트는 해당 Response를 받으면 Refresh Token을 보낸다.
- 서버는 Refresh Token 유효성 체크를 하게 되고, 새로운 Access Token을 발급한다.
- 클라이언트는 새롭게 받은 Access Token을 기존의 Access Token에 덮어쓰게 된다.
access-token 유효성 검사
- 디스패쳐 서블릿에 들어가기 전에, Header에 담겨서 넘어오는 access-token이 유효한지 아닌지를 확인하기 위해서 Filter에서 유효성 검사를 한다.
- 원래라면, access-token이 유효하지 않다면, refresh-token을 확인하여 만료 전이라면 바로 access-token을 발급해주는 과정이 필요하지만…
- 일단은 access-token이 유효하지 않다면, 바로 sessionStorage에서 access-token을 삭제하여 로그아웃되도록 하였다.
그러기 위해서는 filter를 만들어주어야 한다.
JwtAuthFilter
클래스는 JWT 인증 필터를 구현한 클래스로, Spring Security에서 OncePerRequestFilter를 상속하여, 매 요청마다 한 번만 실행되는 필터로 동작한다.
즉, 이 필터의 주요 목적은 들어오는 요청에 대한 JWT 토큰을 검사하여 유효한 경우 인증을 처리하고, 유효하지 않은 경우 401 Unauthorized 응답을 반환한다.
/* JwtAuthFilter */
@Component
public class JwtAuthFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
public JwtAuthFilter(JwtUtil jwtUtil) {
this.jwtUtil = jwtUtil;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
// 1. 요청 HTTP Header의 access-token을 추출한다.
String accessToken = request.getHeader("access-token");
// System.out.println("accessToken : " + accessToken);
// 2. 토큰이 없다면, 인증이 필요없는 요청이므로 필터 체인에 요청을 넘긴다.
// return을 사용하여, 필터를 종료하고 후속 필터로 넘어간다.
if (accessToken == null) {
filterChain.doFilter(request, response);
return;
}
try {
// 3. 토큰이 있다면, 토큰이 유효한지 확인하고 인증을 처리한다.
// 토큰 위조 검사 및 인증 완료 처리 == 유효성 검사
if (accessToken != null) {
// 3-1. jwtUtil의 validate 메서드를 통해 유효성 검사를 진행하고,
// 토큰에 포함된 클레임을 추출한다.
// Jws<Claims>은 JWT의 헤더, 페이로드, 서명 등을 포함하는 객체이다.
Jws<Claims> claims = jwtUtil.validate(accessToken);
// 3-2. 사용자 인증 정보를 담을 User 객체를 생성
User userInfo = new User();
// 클레임 안에 담겨 있는 userNickname 값 얻어오기 => String 타입으로
String userNickname = claims.getBody().get("userNickname", String.class);
// 클레임 안에 담겨 있는 userId 값 얻어오기 => Integer 타입으로
Integer userId = claims.getBody().get("userId", Integer.class);
userInfo.setUserNickname(userNickname);
userInfo.setUserId(userId);
//3-3. 권한 정보(인가 정보) 리스트
// 인증된 사용자에게 부여할 권한 정보를 담을 authorityList 를 생성한다.
// 현재는 권한 정보가 비어있으므로, 추후 권한이 필요한 경우 추가할 수 있다.
List<GrantedAuthority> authorityList = new ArrayList<>();
// 3-4. SecurityContextHolder 를 사용하여 현재 요청의 인증 정보를 설정한다.
// UsernamePasswordAuthenticationToken 객체는 인증된 사용자 userInfo와 권한을 설정한다.
// 비밀번호는 넣을 필요 없으므로 null로 처리한다.
// 이를 SecurityContextHolder에 setAuthentication하여
// Spring Security가 인증된 사용자를 처리하도록 한다.
// principal : 사용자 이름 또는 사용자 객체 , credential : 비밀번호, 주로 null
SecurityContextHolder.getContext()
.setAuthentication(new UsernamePasswordAuthenticationToken(userInfo, null, authorityList));
}
// 4. JWT 토큰이 유효하지 않거나, 토큰 처리 중 예외 발생 시 실행
} catch (Exception e) {
// 토큰이 유효하지 않으면 401 응답
System.out.println("토큰이 위조 되었습니다.");
e.printStackTrace(); // 에러 추적
// 4-1. 401 Unauthorized 응답코드
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
// 4-2. Content-Type을 application/json으로 설정하여,
// 클라이언트가 JSON 형식으로 응답을 받을 수 있게 설정한다.
response.setContentType("application/json");
// 4-3. CORS 정책에 따라 응답을 허용할 도메인 명시
// 클라이언트가 실행 중인 port 번호 작성 (Vue port number)
response.setHeader("Access-Control-Allow-Origin", "http://localhost:5173");
// 4-4. 클라이언트가 인증 정보를 포함하여 요청을 보낼 수 있도록 설정
response.setHeader("Access-Control-Allow-Credentials", "true");
// 4-5. 응답 본문에 JSON 형태의 에러 메시지를 작성
response.getWriter().write("{\"error\": \"Invalid or expired token\"}");
response.getWriter().flush(); // 버퍼에 기록된 데이터를 클라이언트로 즉시 전송
// 4-6. 메서드의 실행을 중단하고, 더 이상의 작업을 진행하지 않도록
// filterChain.doFilter()를 호출하지 않아 요청 처리가 끝난다.
return;
}
// 모든 작업이 완료되면 필터 체인을 통해 다음 필터나 요청 처리를 계속 진행
filterChain.doFilter(request, response);
}
}
이제 클라이언트가 응답 요청을 보내게 되면, 이 Filter가 먼저 실행을 하게 된다.
응답 Header에 access-token이 담겨 있을 경우, 이 Filter를 통해 토큰 유효성 검사가 우선적으로 실행된다.
클라이언트 vue의 코드를 확인해보면 다음과 같다.
- todo와 관련한 js코드를 전역으로 관리하는 파일이다.
/* todo.js */
// 투두 추가하기
const addTodo = (todo, userId) => {
const REST_API_URL = getRestApiUrl(userId) + `/${todo.date}`;
axios
.post(REST_API_URL, todo, {
headers: {
"access-token": sessionStorage.getItem("access-token"),
"Content-Type": "application/json",
},
withCredentials: true,
})
.then((res) => {
console.log("투두 추가하기", res.data);
window.location.reload();
})
.catch((error) => {
if (error.response && error.response.status === 401) {
// 토큰이 만료되었으므로 access-token을 삭제
sessionStorage.removeItem("access-token");
// 로그인 페이지로 리다이렉트
router.push("/login");
}
});
};
axios로 POST 요청을 보낼 때, 요청 header에 access-token를 담아서 보내게 되고,
filter에서 유효성 검사를 잘 통과할 경우 투두가 잘 추가된 것을 확인할 수 있다.
반면, 토큰이 만료되었다면, error가 발생하였으므로 sessionStorage에서 access-token을 삭제하고 로그인 페이지로 보낸다.
'BackEnd > Spring Boot' 카테고리의 다른 글
[Spring Boot] 스프링 부트로 User 회원가입 / 로그인 기능 구현 + 비밀번호 암호화하기 (1) | 2024.11.18 |
---|