
<배경의 내용이 기니 본문만 보고싶을 경우 스킵하세요!>
📌 Filter를 알게된 배경: 멘토님이 다른 팀에게 로그인과 로그인 유지과정은 Spring Security를 통해 하면 좀 더 쉽게 할 수 있을 것이라는 조언을 해주는 것을 듣고 Security가 무언지 찾아보고 우리 프로젝트에도 진행해보고자 하였다.
*Security: Spring기반의 애플리케이션 보안(인증과 권한, 인가 등)을 담당하는 스프링 하위 프레임워크
그러나 이번 프로젝트에서 진행한 로그인은 조금 특수한 상황이었다.
1. 자체 회원가입을 진행하지 않는다.
2. 자체적으로 로그인처리를 진행하지 않는다.

위의 사진 처럼, 이미 LMS라는 업체에 등록한 아이디와 비밀번호를 활용해서 간편로그인을 구현함으로써 LMS의 비밀번호가 바뀔 경우에도 적용이 될 수 있도록, (우리의 웹에서 자체적으로 처리하지 않고)아이디와 비밀번호를 암호화하여 LMS로 보내면 LMS 자체에서 검증 후 성공과 실패 결과를 우리에게 보내주는 구조이다.
*아키텍쳐상으로는 옳지 못한 방법이라고 하였으나, 현재의 상황에 맞춰 타협한 결과 위와 같이 진행하였다.
Spring Security를 활용한 예제들을 찾아보고 '로그인을 한 이후부터 Security를 활용하여 JWT를 통한 인증'을 진행하고자 하고싶었으나 Security의 구조가 파악이 될 수록 위와 같은 상황에 Security를 적용하는게 과연 옳은 것일까 하는 의문과 함께 남은 기간이 얼마 없는 프로젝트 상 Security를 포기하고 JWT 인증을 구현하기로 진행하였는데..!
그래서 생각해낸 첫번째 방법은, JWT인증 라우터를 하나 파서 프론트에서 원하는 컨트롤러로 요청 시 항상 JWT 인증 컨트롤러를 거치고 OK를 받으면 다음으로 원하는 컨트롤러를 요청하는 방식을 생각해 내었는데, 아무리 생각해도 프론트에서 항상 요청을 두번해야 하는 것이 영 아닌 것 같아 방법을 찾다가 Filter, Interceptor의 개념을 알게되었다.
내가 프로젝트 기간 내내 Security를 못놓고 붙잡고 있던 이유 중 하나는 프론트에서 요청을 두번하지 않고 한번에 JWT 인증을 거치고 요청한 컨트롤러로 가게 되는 filter의 존재 때문이었는데, 이는 Security를 사용해야만 사용할 수 있는 줄 알았는데 분리된 개념이었다.
따라서 Filter를 활용하여 JWT인증을 구현하기로 다짐하였다❗️
이번 프로젝트는 SQL Mapper의 한 종류인 MyBatis를 사용하여 @Mapper를 통해 Sql문의 결과와 객체와 매핑하여 진행하였다.
// mybatis 의존성 추가
implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:2.2.2'
| 전체 디렉토리 | config 디렉토리 |
|---|---|
![]() | ![]() |
전반적인 로직은 다음과 같다.
implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.2'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.2'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.2'
우선, JWT를 발급하고 인증하는 로직에 필요한 함수들은 다음과 같다.
package com.example.tamna.config.jwt;
import com.example.tamna.mapper.TokenMapper;
import com.example.tamna.mapper.UserMapper;
import com.example.tamna.model.Token;
import com.example.tamna.model.UserDto;
import io.jsonwebtoken.*;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.security.Key;
import java.util.*;
@Service
@RequiredArgsConstructor
public class JwtProvider implements InitializingBean {
private final TokenMapper tokenMapper;
private final UserMapper userMapper;
@Value("${jwt.secret}")
private String secretKey;
@Value("${jwt.accesstoken-validity-in-seconds}")
private long accessTokenValidityInMilliSeconds;
@Value("${jwt.refreshtoken-validity-in-seconds}")
private long refreshTokenValidityInMilliSeconds;
private Key key;
// afterPropertiesSet() 빈 초기화 시 코드 구현
@Override // Bean이 생성되고 주입받은 후 secretKey값을 Base64 Decode해서 Key변수에 할당
public void afterPropertiesSet(){
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
this.key = Keys.hmacShaKeyFor(keyBytes);
}
public String createAccessToken(String userId) {
Claims claims = Jwts.claims().setSubject(userId);
Date now = new Date();
Date accessValidity = new Date(now.getTime() + accessTokenValidityInMilliSeconds * 1000);
String accessToken = Jwts.builder()
.setClaims(claims) // user 정보
.setIssuedAt(now)
.signWith(key, SignatureAlgorithm.HS256)
.setExpiration(accessValidity) // 만료시간 설정
.compact();
return accessToken;
}
public java.sql.Date time() {
final long miliseconds = System.currentTimeMillis();
return new java.sql.Date(miliseconds);
}
public String createRefreshToken(String userId){
Date now = new Date();
System.out.println("now: " + now);
Date refreshValidity = new Date(now.getTime() + refreshTokenValidityInMilliSeconds * 1000);
String refreshToken = Jwts.builder()
.setSubject("")
.setIssuedAt(now)
.signWith(key, SignatureAlgorithm.HS256)
.setExpiration(refreshValidity)
.compact();
java.sql.Date today = time();
int success = tokenMapper.insertToken(today, userId, refreshToken);
return refreshToken;
}
public Token checkRefresh(String refreshToken){
return tokenMapper.findToken(refreshToken);
}
public String getUserIdFromJwt(String accessToken){
Claims claims = Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(accessToken)
.getBody();
return claims.getSubject();
}
public UserDto checkUser(String accessToken){
String userId = getUserIdFromJwt(accessToken);
return userMapper.findByUserId(userId);
}
public String getHeaderToken(String headerKey, HttpServletRequest request){
String bearerAccessToken = request.getHeader(headerKey);
if (StringUtils.hasText(bearerAccessToken) && bearerAccessToken.startsWith("Bearer ")){
bearerAccessToken = bearerAccessToken.substring(7);
}
return bearerAccessToken;
}
public String getResHeaderAccessToken(String headerKey, HttpServletResponse response){
String bearerAccessToken = response.getHeader(headerKey);
if (StringUtils.hasText(bearerAccessToken) && bearerAccessToken.startsWith("Bearer ")){
bearerAccessToken = bearerAccessToken.substring(7);
}
return bearerAccessToken;
}
public Map<Boolean, String> validateToken(String token){
Map<Boolean, String> result = new HashMap<>();
try{
Jws<Claims> claims = Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
result.put(!claims.getBody().getExpiration().before(new Date()), "success");
return result;
}catch (ExpiredJwtException e){
System.out.println("만료된 JWT");
result.put(true, "fail");
return result;
}catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
System.out.println("잘못된 JWT 서명");
}catch (UnsupportedJwtException e){
System.out.println("지원되지 않는 JWT");
}catch (IllegalStateException e){
System.out.println("JWT 토큰 잘못됨");
}catch (IllegalArgumentException e){
System.out.println("JWT 토큰 없음");
}
result.put(false, "유호하지 않음");
return result;
}
public String deleteToken(String refreshToken) {
int result = tokenMapper.deleteToken(refreshToken);
if (result > 0) {
return "success";
}
return "fail";
}
}
프론트에서 LMS를 통해 로그인 성공 응답을 받으면 유저의 아이디를 바디에 담아 JWT발급 라우터로 요청을 하는 구조이다.
controller
package com.example.tamna.controller;
import com.example.tamna.service.AuthService;
import io.swagger.annotations.ApiOperation;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpServletResponse;
import java.util.HashMap;
import java.util.Map;
@RestController
@RequestMapping("/auth")
@RequiredArgsConstructor
public class AuthController {
@Value("${AUTHORIZATION_HEADER}")
private String AUTHORIZATION_HEADER;
@Value("${REAUTHORIZATION_HEADER}")
private String REAUTHORIZATION_HEADER;
@Value("${jwt.token-prefix}")
private String tokenPrefix;
private final AuthService authService;
@Data
static class UserId{
private String userId;
}
@ApiOperation(value = "JWT 발급")
@PostMapping(value = "/login")
@ResponseBody
public ResponseEntity<Map<String, String>> createToken(@RequestBody UserId userId, HttpServletResponse response){
String getUserId = userId.userId;
Map<String, String> tokenMap = authService.login(getUserId);
if(tokenMap.get("message") != null) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(tokenMap);
}
response.addHeader(AUTHORIZATION_HEADER, tokenPrefix + tokenMap.get("access"));
response.addHeader(REAUTHORIZATION_HEADER, tokenPrefix + tokenMap.get("refresh"));
Map<String, String> map = new HashMap<>();
map.put("message", "success");
return ResponseEntity.status(HttpStatus.OK).body(map);
}
@ApiOperation(value = "로그아웃")
@GetMapping(value = "/logout")
public ResponseEntity<Map<String, String>> logout(HttpServletResponse response){
Map<String, String> map = new HashMap<>();
String result = authService.logOutCheckUser(response);
map.put("message", result);
return ResponseEntity.status(HttpStatus.OK).body(map);
}
}
service
package com.example.tamna.service;
import com.example.tamna.config.jwt.JwtProvider;
import com.example.tamna.mapper.UserMapper;
import com.example.tamna.model.UserDto;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import javax.servlet.http.HttpServletResponse;
import java.util.HashMap;
import java.util.Map;
@Service
@RequiredArgsConstructor
public class AuthService {
private final UserMapper userMapper;
private final JwtProvider jwtProvider;
@Value("${AUTHORIZATION_HEADER}")
private String AUTHORIZATION_HEADER;
@Value("${REAUTHORIZATION_HEADER}")
private String REAUTHORIZATION_HEADER;
// 로그인 시 토큰 생성
public Map<String, String> login(String userId) {
Map<String, String> map = new HashMap<>();
UserDto user = userMapper.findByUserId(userId);
if (user != null) {
String access = jwtProvider.createAccessToken(user.getUserId());
String refresh = jwtProvider.createRefreshToken(user.getUserId());
map.put("access", access);
map.put("refresh", refresh);
} else {
map.put("message", "fail");
}
return map;
}
// 로그아웃 refreshToken 삭제
public String logOutCheckUser(HttpServletResponse response){
String accessToken = jwtProvider.getResHeaderAccessToken(AUTHORIZATION_HEADER, response);
String refreshToken = jwtProvider.getResHeaderAccessToken(REAUTHORIZATION_HEADER, response);
String result;
if(accessToken != null && refreshToken != null) {
return result = jwtProvider.deleteToken(refreshToken);
}else{
return null;
}
}
}
package com.example.tamna.config.jwt;
import com.example.tamna.model.Token;
import com.example.tamna.model.UserDto;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Map;
@RequiredArgsConstructor
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtProvider jwtProvider;
@Value("${AUTHORIZATION_HEADER}")
private String AUTHORIZATION_HEADER;
@Value("${REAUTHORIZATION_HEADER}")
private String REAUTHORIZATION_HEADER;
@Value("${ADMIN_HEADER}")
private String ADMINAUTHORIZATION_HEADER;
@Value("${jwt.token-prefix}")
private String tokenPrefix;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
if (!request.getMethod().equals("OPTIONS") && (request.getRequestURI().startsWith("/auth")
|| request.getRequestURI().startsWith("/admin"))) {
if (!request.getRequestURI().startsWith("/auth") && !request.getRequestURI().equals("/admin/login")
&& !request.getRequestURI().startsWith("/admin/logout")) {
// admin 토큰 검증
String adminAccessToken = jwtProvider.getHeaderToken(ADMINAUTHORIZATION_HEADER, request);
Map<Boolean, String> accessResult = jwtProvider.validateToken(adminAccessToken);
if (accessResult.isEmpty() && !accessResult.containsKey(true)) {
response.sendError(403);
return;
}
UserDto user = jwtProvider.checkUser(adminAccessToken);
if (!user.getRoles().equals("ADMIN")) {
response.sendError(404); // 어드민 페이지 권한 x
return;
}
response.setHeader(ADMINAUTHORIZATION_HEADER, tokenPrefix + adminAccessToken);
}
} else if (!request.getMethod().equals("OPTIONS")) {
String accessToken = jwtProvider.getHeaderToken(AUTHORIZATION_HEADER, request);
String refreshToken = jwtProvider.getHeaderToken(REAUTHORIZATION_HEADER, request);
if (accessToken == null) {
response.sendError(403);
return;
}
Map<Boolean, String> accessResult = jwtProvider.validateToken(accessToken);
if (accessResult.isEmpty() && !accessResult.containsKey(true)) {
response.sendError(403);
return;
}
if (accessResult.containsValue("success")) {
response.setHeader(AUTHORIZATION_HEADER, tokenPrefix + accessToken);
response.setHeader(REAUTHORIZATION_HEADER, tokenPrefix + refreshToken);
} else { // assess 만료
if (refreshToken == null) {
response.sendError(403);
return;
}
Token checkRefresh = jwtProvider.checkRefresh(refreshToken);
if (checkRefresh == null) {
response.sendError(403);
return;
}
Map<Boolean, String> refreshResult = jwtProvider.validateToken(refreshToken);
if (refreshResult.isEmpty() && !refreshResult.containsKey(true)
&& !refreshResult.containsValue("success")) {
jwtProvider.deleteToken(refreshToken);
response.sendError(403);
return;
}
String newAccessToken = jwtProvider.createAccessToken(checkRefresh.getUserId());
response.setHeader(AUTHORIZATION_HEADER, tokenPrefix + newAccessToken);
response.setHeader(REAUTHORIZATION_HEADER, tokenPrefix + refreshToken);
}
}
filterChain.doFilter(request, response);
}
}
포스트맨을 통해서는 위와 같은 코드만으로도 잘 되었으나 브라우저와의 통신에서 오류가 발생했다.
해당 JWT를 실어보낼 Header의 키값들을 모두 추가로 등록해줘야한다!
package com.example.tamna.config;
import io.swagger.models.HttpMethod;
import org.springframework.beans.factory.annotation.Value;
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 CorsConfig implements WebMvcConfigurer{
@Value("${AUTHORIZATION_HEADER}")
private String ACCESSTOKEN_HEADER;
@Value("${REAUTHORIZATION_HEADER}")
private String REFRESHTOKEN_HEADER;
@Value("${ADMIN_HEADER}")
private String ADMINTOKEN_HEADER;
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowCredentials(true)
.allowedOriginPatterns("*")
.allowedMethods(HttpMethod.GET.name(), HttpMethod.POST.name())
.allowedHeaders("*")
.exposedHeaders(ACCESSTOKEN_HEADER, REFRESHTOKEN_HEADER, ADMINTOKEN_HEADER)
.maxAge(3000);
WebMvcConfigurer.super.addCorsMappings(registry);
}
}
우리의 웹은 모든 페이지에서 유저의 데이터가 필요함으로 컨트롤러마다 Filter를 거친 후 응답 헤더의 jwt를 통해 유저의 정보를 가져오도록 구현하였다.
대표적인 예시)
controller
@ApiOperation(value = "마이페이지 데이터")
@GetMapping(value = "/mypage")
public ResponseEntity<Map<String, Object>> myBookingState(HttpServletResponse response){
// 요청 헤더의 토큰을 통해
UserDto user = authService.checkUser(response);
Map<String, Object> map = new HashMap<>();
if(user != null) {
map.put("userData", user);
.. 로직생략
return ResponseEntity.status(HttpStatus.OK).body(map);
}else{
map.put("message", "tokenFail");
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(map);
}
}
service
public UserDto checkUser(HttpServletResponse response){
String accessToken = jwtProvider.getResHeaderAccessToken(AUTHORIZATION_HEADER, response);
if(accessToken!= null) {
return jwtProvider.checkUser(accessToken);
}
return null;
}
정말.. 이렇게 정리하고 보니 코드가 많이 더럽지만 ㅠㅠ,, 처음 구현함에도 불구하고 내가 생각한 로직을 적용했다는 점과 완성돼 동작된다는 점에 너무나도 만족스럽다! 🥹
생각의 관점 하나만 바꾸어도 동작되는 것이 달라진다는 것❗️
'(2.3.6) 헤더의 실어진 JWT을 통해 유저 정보 가져오기'을 구현하면서, 처음에는 요청헤더의 실어진 JWT을 통해 구현하였는데 이는 access토큰이 만료되어 재발급 되는 경우에는 적용되지 못하는 문제점을 가졌었다.🥲 즉, 요청헤더의 access토큰은 만료되었고 새로 발급된 access토큰은 응답헤더에 실리기 때문에 요청헤더를 통해 구현했을 경우에는 user의 데이터를 가져오지 못하는 오류를 겪었다. 이걸 해결하기 위해서 정말 로직을 다시 짜야하나 엄청난 생각을 하다가 응답헤더를 통해 구현하면 되는 것이구나! 하는 깨달음을 얻었다.. 정말.. 이걸 깨닫지 못했다면 며칠을 삽질했을 걸 생각하니..끔찍하다😣😖
코드 보완
현재 모든 컨트롤러에서 '(2.3.6) 헤더의 실어진 JWT을 통해 유저 정보 가져오기'의 아래와 같은
코드는 중복된다.
UserDto user = authService.checkUser(response);
if(user != null) {
.. 로직생략
return ResponseEntity.status(HttpStatus.OK).body(map);
}else{
map.put("message", "tokenFail");
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(map);
}
위와 같은 공통된 로직을 한번에 묶어서 구현하도록 수정하고 싶은데,,(컨트롤러 전에 헤더의 토큰을 가져와 유저의 정보를 가져오고, 1) 만약 유저의 정보가 null이 아닌 경우에는 컨트롤러로 유저 데이터를 넘기고 2) null인 경우 프론트로 상태코드 403을 보내고 싶었다.)
이렇게 구현한다면 모든 컨트롤러의 라우터들마다 위와 같은 코드가 중복되지 않고 한곳에서 관리가 될 것이라고 생각했다. 그러나 Interceptor는 반환 값이 boolean이라 내가 원하는 로직과는 맞지 않았고, AOP를 통해 구현할 수 있을 것 같은데, 나중에 시간이 난다면 한번 진행해 봐야겠다.