[Spring] JWT 로그인 구현하기 2
이멀젼씨
·2021. 10. 25. 15:05
목적
기존에 구현한 JWT로그인을 보완하고자 함
목차
- 기존의 흐름
- 새로 구현한 JWT로그인의 흐름
- 코드 설명
기존의 흐름
https://emgc.tistory.com/133
기존에 작성한 JWT로그인이며 흐름은 아래와 같다.
필터
로그인 시 쿠키에 있는 토큰을 확인하고 유효한 토큰이라면 인증객체를 생성하고 요청을 넘긴다
컨트롤러
인증객체의 확인 없이 클라이언트가 입력한 아이디와 비밀번호를 통해 사용자를 인증하고 인증된 사용자의 정보로 토큰을 발급한다.
하지만 아래와 같은 문제점이 있어서 새로운 JWT로그인이 필요했다.
토큰을 갖고 있어도 매번 아이디 비밀번호를 입력해야 로그인 가능 토큰 자체의 만료시간이 짧기 때문에 만료될때마다 아이디 비밀번호를 입력
새로 구현한 JWT로그인의 흐름
토큰을 access token, refresh token 두 가지로 구분하였다.
이유는 아래와 같다.
- 한 가지 토큰만 사용했을 경우 토큰이 탈취된다면 해당 토큰으로 사용자의 정보에 접근이 가능하다. 이를 막고자 토큰의 유효기간은 짧게 만들고, 또 다른 토큰을 만들어 이를 보완하고자 하였다.
- 만료 기간이 짧아지면 자주 로그인을 해주어야 하며 이는 사용자에게 굉장한 불편을 가져다준다.
각 토큰은 개발자 입맛에 따라 어떠한 정보를 넣을지, 유효기간은 얼마나 설정할 지 등 커스텀이 가능하다.
필자는 access token과 refresh token 모두 동일한 정보를 갖고 있도록 하고, refresh token의 만료기간만 아주 길게 설정하였다.
전체적인 흐름
access token 검증
access token을 먼저 검증한다.
access token이 유효한 토큰이라면 인증객체를 생성한다.
refresh token 검증
access token이 유효하지 않은 경우 refresh token을 검증한다.
refresh token이 유효한 토큰이라면 인증객체를 생성한다.
인증객체 확인
컨트롤러에서 인증객체가 생성되어있는지 확인한다.
인증객체가 생성되어 있다면 사용자 인증이 되었으므로 인증객체로 토큰을 생성하여 발급한다.
유저 아이디 비번 검증
인증객체가 없다면 사용자의 아이디와 비밀번호로 검증한다.
아이디 비밀번호가 일치하는 경우 토큰을 발급한다.
1,2번은 필터에서 3,4번은 컨트롤러에서 진행된다.
인증객체 저장소를 두고 각각 독립적으로 필터와 컨트롤러가 객체를 생성 및 접근하도록 하여 서로 의존하지 않도록 만들었다.
코드 설명
의존성 추가
- JWT의 관리를 도와주는 의존성을 추가한다.
implementation 'io.jsonwebtoken:jjwt:0.9.1'
만료된 refresh token 관리
refresh token은 만료기간이 길기 때문에 기존의 토큰이 사용될 우려가 있다. 따라서 로그인 시 새롭게 refresh token을 발급되기 때문에 기존의 토큰을 사용하지 못하도록 조치를 취해야 한다.
만료된 refresh token을 관리하는 ExpiredRefreshToken 엔티티와 ExpiredRefreshTokenRepository, ExpiredRefreshTokenService를 생성한다
@Entity
@Getter
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PROTECTED)
@EntityListeners(AuditingEntityListener.class)
public class ExpiredRefreshToken {
@Id
@GeneratedValue
private Long id;
private String token;
}
ExpiredRefreshTokenRepository
public interface ExpiredRefreshTokenRepository extends JpaRepository<ExpiredRefreshToken, Long> {
boolean existsByToken(String token);
}
ExpiredRefreshTokenService
@Service
@RequiredArgsConstructor
public class ExpiredRefreshTokenService {
private final ExpiredRefreshTokenRepository expiredRefreshTokenRepository;
public boolean isExpiredToken(String token) {
return expiredRefreshTokenRepository.existsByToken(token);
}
public ExpiredRefreshToken addExpiredToken(String token) {
ExpiredRefreshToken saveToken = ExpiredRefreshToken.builder()
.token(token)
.build();
return expiredRefreshTokenRepository.save(saveToken);
}
}
JWT를 관리하는 JwtUtil
jwt를 관리하는 JwtUtil을 생성해준다.
refresh token과 access token을 생성 방식은 동일하게 설정해주었다.
필요에 따라 다른 로직으로 생성해줄 수 있다.
refresh token의 검증에는 ExpiredRefreshtokenService에서 만료된 토큰인지 확인하고 만료되지 않은 경우에만 validateToken메소드에 토큰 검증을 위임한다.
@Component
@RequiredArgsConstructor
public class JwtUtil {
private final ExpiredRefreshTokenService expiredRefreshTokenService;
private final UserService userService;
@Value("${jwt.secret}")
private String secretKey;
private final long ACCESS_TOKEN_VALID_TIME = 1000L * 60 * 60; //1시간
private final long REFRESH_TOKEN_VALID_TIME = 1000L * 60 * 60 * 24 * 60; // 2달
public String createAccessToken(String userPk, String role) {
Claims claims = Jwts.claims().setSubject(userPk);
claims.put("role", role);
Date now = new Date();
return Jwts.builder()
.setClaims(claims)
.setIssuedAt(now)
.setExpiration(new Date(now.getTime() + ACCESS_TOKEN_VALID_TIME))
.signWith(SignatureAlgorithm.HS256, secretKey)
.compact();
}
public String createRefreshToken(String userPk, String role) {
Claims claims = Jwts.claims();
claims.put("role", role);
Date now = new Date();
Date expiration = new Date(now.getTime() + REFRESH_TOKEN_VALID_TIME);
return Jwts.builder()
.setClaims(claims)
.setIssuedAt(now)
.setExpiration(expiration)
.signWith(SignatureAlgorithm.HS256, secretKey)
.compact();
}
public Authentication getAuthentication(String token) {
String email = getUserPk(token);
User user = userService.getUser(email);
return new UsernamePasswordAuthenticationToken(user, "", user.getAuthorities());
}
public String getUserPk(String token) {
return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody().getSubject();
}
public String resolveAccessToken(HttpServletRequest request) {
String token = request.getHeader("access-token");
return token;
}
public String resolveRefreshToken(HttpServletRequest request) {
String token = null;
Cookie cookie = WebUtils.getCookie(request, "refresh-token");
if (cookie != null)
token = cookie.getValue();
return token;
}
public boolean validateToken(String jwtToken) {
try {
Jws<Claims> claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwtToken);
return !claims.getBody().getExpiration().before(new Date());
} catch (Exception e) {
return false;
}
}
public boolean validateRefreshToken(String jwtToken) {
if(expiredRefreshTokenService.isExpiredToken(jwtToken)) {
return false;
}
return validateToken(jwtToken);
}
}
JWT인증 필터
jwt인증 필터를 만들어주고, 생성자 파라미터로 jwtUtil을 주입받는다.
header에 access token을 먼저 확인한다. 유효한 경우라면 토큰으로부터 유저 정보를 불러와 인증객체를 만든다.
유효하지 않다면 cookie에 있는 refresh token을 확인한다. 토큰이 유효한 경우 마찬가지로 인증객체를 만들어 저장한다.
만약 access token, refresh token 둘 다 유효하지 않거나 없는 경우 인증객체를 생성하지 않고 요청을 다음 필터로 넘긴다.
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
public JwtAuthenticationFilter(JwtUtil jwtUtil) {
this.jwtUtil = jwtUtil;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String accessToken = jwtUtil.resolveAccessToken(request);
boolean isAccessTokenValid = accessToken != null && jwtUtil.validateToken(accessToken);
try {
if (isAccessTokenValid) {
Authentication authentication = jwtUtil.getAuthentication(accessToken);
SecurityContextHolder.getContext().setAuthentication(authentication);
} else {
String refreshToken = jwtUtil.resolveRefreshToken(request);
if (refreshToken != null && jwtUtil.validateRefreshToken((refreshToken))) {
Authentication authentication = jwtUtil.getAuthentication(refreshToken);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
} catch (Exception e) {
}
filterChain.doFilter(request, response);
}
}
Spring Security 설정
- Spring Security에서 UsernamePasswordAuthenticationFilter 이전에 동작하도록 설정한다.
- /login 경로에 대한 접근은 모두 허용한다.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private final JwtUtil jwtUtil;
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.httpBasic().disable()
.csrf().disable()
.formLogin().disable()
.cors()
.and()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
.antMatchers("/login/**").permitAll()
.anyRequest().authenticated()
.and()
.addFilterBefore(new JwtAuthenticationFilter(jwtUtil), UsernamePasswordAuthenticationFilter.class);
}
}
컨트롤러에서 인증
- 컨트롤러에 로그인 핸들러 메소드를 만들어준다.
- 인증객체가 생성되어있는지 확인한다. 생성 되어있다면 인증객체로부터 데이터를 꺼내어 새롭게 토큰을 만들어준다.
- 인증객체가 생성되어있지 않다면 유저가 입력한 아이디와 비밀번호를 확인하게 된다.
이를 통해 맞다면 토큰 발급 틀리다면 토큰을 발급하지 않는다.
@RestController
@RequiredArgsConstructor
public class UserApi {
private final UserService userService;
private final PasswordEncoder passwordEncoder;
private final ExpiredRefreshTokenService expiredRefreshTokenService;
@PostMapping("/login")
public ResponseEntity<UserRes> login(@RequestBody(required = false) @Valid UserLoginReq req,
HttpServletRequest request,
HttpServletResponse response) {
User user = null;
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication.isAuthenticated() && !(authentication instanceof AnonymousAuthenticationToken)) {
user = (User)authentication.getPrincipal();
} else {
if (req != null) {
user = userService.getUser(req.getEmail());
if (!passwordEncoder.matches(req.getPassword(), user.getPassword())) {
//아이디 비밀번호 미일치 시 처리할 로직
}
}
}
if (user == null) {
//유저객체를 제대로 불러오지 못할때 처리할 로직
}
String expiredToken = jwtUtil.resolveRefreshToken(request);
if (expiredToken != null && !expiredToken.isBlank()) {
expiredRefreshTokenService.addExpiredToken(expiredToken);
}
String accessToken = jwtUtil.createAccessToken(user.getEmail(), user.getRole().name());
String refreshToken = jwtUtil.createRefreshToken(user.getEmail(), user.getRole().name());
Cookie refreshTokenCookie = new Cookie("refresh-token", refreshToken);
response.setHeader("access-token", accessToken);
response.addCookie(refreshTokenCookie);
return new ResponseEntity<>(user, HttpStatus.OK);
}
}
유효한 access token 로그인
필터에서 인증객체를 만든다.
컨트롤러에서 인증객체를 꺼내 토큰을 만들어 발급한다.만료된 access token, 유효한 refresh token 로그인
필터에서 refresh token으로 인증객체를 만든다.
위와 마찬가지로 컨트롤러에서 인증객체를 꺼내 토큰을 만들어 발급한다.유효하지 않은 access token과 refresh token
컨트롤러에서 사용자의 아이디 비밀번호를 검증한다. 올바른 정보인 경우 토큰 발급, 그렇지 않은 경우 예외를 던진다.
'백엔드 > Spring' 카테고리의 다른 글
[Spring] 이미지를 파일로 저장하지 않고 업로드 하기 (0) | 2023.03.20 |
---|---|
[Spring] bootBuildImage와 "No compatible attachment provider is available" (0) | 2021.12.01 |
[Spring] 생성자 주입을 사용해야 하는 이유 (0) | 2021.08.27 |
[Spring] GeoIp2를 사용한 해외 아이피 차단 (0) | 2021.07.28 |
[Spring] JASYPT를 사용한 프로퍼티 암호화 (1) | 2021.07.27 |