2024-02-18
+) 리팩토링하면서 코드가 수정됐다!
태초에 첫 토큰 로그인 구현이 있었다.
Spring Security ) JWT 토큰 로그인 구현을 해보았다
이것은 드디어 토큰 로그인을 완성한 나를 위한 박수 박수.... 그리고 정리와 저장용... 일단 난 1. Access 토큰은 프론트(로컬스토리지)에 저장 2. Refresh 토큰은 서버(DB)에 저장 했고 3. 권한은 따로
hyuil.tistory.com
조금 보완한 부분도 있고 프론트 서버와 통신하는 용도로 썼기 때문에
약~~~간 달라진 부분도 있어 다시 정리해 글을 써본다.!!!!!!^_^
클래스 구성은
SecurityConfig
@EnableWebSecurity
@Configuration
@RequiredArgsConstructor
public class SecurityConfig {
private final TokenService tokenService;
private final UserDetailsService userDetailsService;
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(req -> req
.requestMatchers("/api/v1/main","/api/v1/tag", "/api/v1/memo")
.hasRole("USER")
.anyRequest().permitAll())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.addFilterBefore(new JwtFilter(tokenService, userDetailsService), UsernamePasswordAuthenticationFilter.class)
.build();
}
}
JAVA17 부터는 Spring Boot 구성이 좀 달라져서 스프링 시큐리티 설정도 좀 바뀌었다.
@EnableWebSecurity
@Configuration
시큐리티 설정 클래스에 해당 애노테이션을 반드시 붙이고
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(req -> req
.requestMatchers("/api/v1/main","/api/v1/tag", "/api/v1/memo")
.hasRole("USER")
.anyRequest().permitAll())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.addFilterBefore(new JwtFilter(tokenService, userDetailsService), UsernamePasswordAuthenticationFilter.class)
.build();
}
SecurityFilterChain 클래스를 빈으로 등록해줘야 한다.
설정도 람다로 해주면 된다. 아마 가독성을 위해 이렇게 바꾼듯 싶다.
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.addFilterBefore(new JwtFilter(tokenService, userDetailsService), UsernamePasswordAuthenticationFilter.class)
토큰을 사용할 때 session 을 STATELESS 를 해주는 건 당연하고, (검색하면 이유 많이 나옴)
UsernamePasswordAuthenticationFilter 는 인증 필터인데
인증 필터 전에 Jwt 필터를 추가해서 우리가 할 인증을 추가시켜준다.
JwtFilter
@Slf4j
@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {
private final TokenUseCase tokenService;
private final UserDetailsService userDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
/**
* 토큰 존재 여부 확인 (헤더)
* 토큰 유효 확인
* 토큰 다시 생성
* 토큰 쿠키 넣기
* 어썬티케이션 생성
* 필터 체인
*/
// 토큰 존재 여부 확인
String token = request.getHeader(HttpHeaders.AUTHORIZATION);
if (!StringUtils.hasText(token)) {
filterChain.doFilter(request, response);
return;
}
// 'Bearer' + ' ' 띄어쓰기 포함해서 제거
token = token.substring(JwtToken.BEARER.getValue().length() + 1);
// 토큰 유효 확인
boolean valid = tokenService.tokenValid(token);
if (!valid) {
log.debug("토큰 일치하지 않음.");
HttpSession session = request.getSession(false);
session.invalidate();
filterChain.doFilter(request, response);
return;
}
// 토큰 재생성
String reCreateToken = tokenService.reCreateToken(token);
// 헤더 셋팅
setTokenAtHeader(response, reCreateToken);
// Authentication 생성
setAuthenticationAtSecurityContextHolder(token);
filterChain.doFilter(request, response);
}
private void setTokenAtHeader(HttpServletResponse response, String reCreateToken) {
response.setHeader(HttpHeaders.AUTHORIZATION, JwtToken.BEARER.getValue() + " " + reCreateToken);
}
private void setAuthenticationAtSecurityContextHolder(String token) {
String username = tokenService.usernameByToken(token);
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, token, userDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
주석을 달아놨으니 간단히 설명하겠다.
토큰 존재 여부는 HTTP 헤더 Authorization 으로만 확인한다.
그 전에는 백엔드에서 thymeleaf 로 프론트까지 해결했기 때문에 헤더나 uri 파라미터까지 확인했던 것이고...
이제는 프론트에서 모든 요청이 Authorization 헤더로 올 것이기 때문에...다른 건 볼 필요도 없음 ^.^
그리고 토큰이 유효한지 확인해주고 혹시 유효하지 않다면 세션을 삭제해줬다.
이게 맞는 코드인진 모르겠는데... 왠지 로그인은 안 돼있어도 세션이 남아있을 수도 있다고 생각했고
그러면 서버에 부하가 일어나잖아잉~ 그건 싫어잉~
토큰을 재생성한 뒤 토큰은 쿠키로 보냈다.
그리고 Authentication 객체를 생성 후 SecurityContext에 넣어주면 인증 완료 ! ^_^ /)
이번에는 Refresh 토큰을 사용하지 않았는데
그 이유는 리프레쉬 토큰은 보통 서버에 저장한다고 하고
그럼 내 생각엔 DB 밖에 저장할 데가 없는데...
리프레쉬 토큰을 매번 가져오거나 저장하기 위해
JDBC 를 사용해 DB 를 왔다갔다 하는 일은
자원을 불필요하게 사용하는 행위 같다.
그리고 어떤 회사에서는 세션을 DB 에 저장하기 때문에
세션에 인증 토큰을 넣으면 DB 를 매번 들리기 위해
어느 DB에 해당 세션이 있는지 찾아야하고 그러기 위해 계속 JDBC 를 사용해야하고...
이 작업이 굉장히 자원을 많이 소모하는 일이라고 하더라고?
그래서 이번에는 리프레쉬 토큰을 넣지 않고 그냥 액세스 토큰만 사용했다.
뭐가 나은 방법인진 나도 모른당 ! ㅎㅎㅎ 그냥 이번엔 그렇게 했다!
2024-02-18
+)
리프레쉬 토큰은 Cookie 에 보내서 왔다갔다 하는 방법이 있다고 한다. 그리고 편리하다!
일단 지금은 JWT 만 있으니 response 헤더에 토큰을 주고
다음에는 refresh 토큰을 만드는 작업을 한다면 쿠키에 보내는 것으로 !
이번에는 저번 코드와는 다르게 필터에서는 오직! 서비스만 사용하도록 만들어서
Parser 나 Provider 코드는 보이지 않는다.
필터가 직접 토큰을 만들거나 파싱하지 않고 Service 에 위임한다 (뿌듯)
TokenService
@Service
@RequiredArgsConstructor
public class TokenService implements TokenUseCase {
private final TokenProvider tokenProvider;
private final TokenParser tokenParser;
@Override
public String creatToken(Long id, String username) {
return tokenProvider.createToken(id, username);
}
@Override
public String reCreateToken(String token) {
Long id = tokenParser.getId(token);
String username = tokenParser.getUsername(token);
return tokenProvider.createToken(id, username);
}
@Override
public boolean tokenValid(String token) {
return tokenParser.isValid(token);
}
@Override
public String usernameByToken(String token) {
return tokenParser.getUsername(token);
}
@Override
public Long getIdByToken(String token) {
return tokenParser.getId(token);
}
}
그래서 역시 Service 가 직접 Paser 와 Provider 를 이용해 토큰을 파싱하고 생성한다.
나는 토큰에 id, username 을 전부 넣었기 때문에 id와 username 은 반드시 넣어주고
토큰을 새로 만드는 코드와 기존 토큰을 받아 새로운 토큰을 만드는 코드는 따로 작성했다.
토큰을 직접 사용(?)하는 모든 코드는 다 이쪽에 넣어버렸다.
TokenProvider
// 토큰 생성 클래스
@Component
@PropertySource("classpath:application.yml")
public class TokenProvider {
// 토큰 유효 시간
@Value("${token.valid.time}")
private int tokenValidTime;
private final Key key;
public TokenProvider(@Value("${token.secret.key}") String secretKey) {
key = Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8));
}
public String createToken(Long id, String username) {
Claims claims = getClaims(id, username);
return Jwts.builder()
.setHeaderParam(JwtToken.TYP.getValue(), JwtToken.BEARER.getValue())
.setClaims(claims)
.signWith(key, SignatureAlgorithm.HS512)
.compact();
}
private Claims getClaims(Long id, String username) {
Date now = new Date();
Claims claims = Jwts.claims()
.setSubject(JwtToken.ACCESS_TOKEN.getValue())
.setIssuedAt(now)
.setExpiration(new Date(now.getTime() + tokenValidTime));
claims.put(JwtToken.ID.getValue(), id.toString());
claims.put(JwtToken.USERNAME.getValue(), username);
claims.put(JwtToken.ROLE.getValue(), JwtToken.ROLE_USER.getValue());
return claims;
}
}
토큰 유효 시간이나 시크릿 키 같은 경우는
이번에도 코드에 직접 노출하지 않고 application.yml 에 값을 숨긴 뒤 @Value 를 이용해 사용했다.
간단하게 UTF-8 로 시크릿 키를 키로 사용하는 내용과
토큰을 만들고 클래임을 가져오는 코드만 보인다.
사실 엄밀히 말하면 클래임은 private 코드로 토큰을 만드는 메소드가 사용하는 것이기 때문에
토큰을 만드는 메소드만 있는 클래스다.
하지만 객체 지향의 5원칙중에
하나의 범용 인터페이스보다 여러 개의 기능으로 분리되어있는 인터페이스가 낫다!!는 말이 있다.
그래서 그냥 토큰을 만드는 클래스는 하나로 따로 떼어놓았다.!!! 캬캬
TokenParser
// 토큰 파싱 클래스
@Slf4j
@Component
public class TokenParser {
private final JwtParser jwtParser;
public TokenParser(@Value("${token.secret.key}") String secretKey) {
Key key = Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8));
this.jwtParser = Jwts.parserBuilder().setSigningKey(key).build();
}
public boolean isValid(String token) {
Claims claims = getClaims(token);
if (claims.getSubject() != null) {
return !claims.getExpiration().before(new Date());
}
return false;
}
public Long getId(String token) {
Claims claims = getClaims(token);
return Long.parseLong(claims.get(JwtToken.ID.getValue(), String.class));
}
public String getUsername(String token) {
Claims claims = getClaims(token);
return claims.get(JwtToken.USERNAME.getValue(), String.class);
}
private Claims getClaims(String token) {
return jwtParser.parseClaimsJws(token).getBody();
}
}
간단하게 토큰을 파싱하는 클래스다.
유효성을 검사하고 id나 username을 얻는다. 그러기 위해 토큰 바디도 얻는다.
유효성 검사는 간단하게 지금 날짜와 토큰에 써진 날짜를 비교해
토큰 유효 기간이 지금 시간보다 지나있으면 유효하지 않은 걸로 했다 !ㅋ_ㅋ
2024-02-18 추가된 클래스
JwtToken (enum)
@Getter
public enum JwtToken {
TOKEN("token"),
ACCESS_TOKEN("access_token"),
TYP("typ"),
BEARER("Bearer"),
ID("id"),
USERNAME("username"),
ROLE("role"),
ROLE_USER("[ROLE_USER]")
;
private final String value;
JwtToken(String value) {
this.value = value;
}
}
토큰 관련해서 사용하는 이름들을 enum 으로 처리하고 넣어버리기~^_^ /)
이래야 변경될 때 관리가 편하지롱!
그래도 전에 썼던 코드보다는 많이 깔끔하고 좋은듯 ㅋ
(물론 리프레쉬 토큰을 쓰지 않아서도 한 몫 하는듯 ㅋ)
리프레쉬 토큰 사용 여부에 대해서는 좀 더 고민하고 여기저기 물어봐야할듯 함...
또 고수 개발자님께 여쭤봐야지 ㅎ
일반적으로 리프레쉬 토큰은 쿠키에 저장해서 왔다갔다 통신한다고 한다.
이 프로젝트에서는 일단 리프레쉬 토큰을 사용하지 않으니 상관없지만, 다음에 사용할 땐 쿠키에 집어 넣는 것으로...
'혼자서 개발새발' 카테고리의 다른 글
Java) Decorator 패턴을 사용해보자! (1) | 2023.12.28 |
---|---|
MAC(M2) 에 ubuntu 를 설치해서 리눅스를 환경을 이용해보자! (0) | 2023.10.19 |
intelliJ ) POSTMAN 보다 편리하게 HTTP Test 하기 (0) | 2023.06.22 |
Spring Security ) SNS 로그인(OAuth2) + JWT + redirect 를 전부 구현하기 (0) | 2023.05.06 |
Pageable 로 게시판 페이징을 해야 하는데 특정 컬럼이 존재한다면 안 보여주고 싶다 (0) | 2023.05.05 |