구현할 것
security filter chain을 이용하지 않고, Interceptor를 사용하여 Jwt 토큰으로 회원 인증 인가 로직을 구현합니다.
또한 swagger가 이미 프로젝트에 포함된 경우 Jwt 토큰 인증 방식을 추가하는 경우 swagger는 토큰을 들고 있지 않아서 이전처럼 api명세를 확인하려면 Interceptor에 적용되는 url에 대한 설정이 필요합니다. 이에 대해 다룹니다.
build.gradle 종속성 추가
depedencies{
// JWT
compileOnly group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.5'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.5'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.5'
// json
implementation 'org.json:json:20230227'
}
JWT 종속성과, JWT안에서 사용되는 json 종속성을 추가합니다.
다음으로 Jwt 토큰 생성, 검증, 추출 등의 로직을 담당하는 JwtUtil 클래스를 생성합니다.
JwtUtil 클래스
@Slf4j(topic = "jwt") //@1 Log
@Component
public class JwtUtil {
// @2 필요한 값들
// Header KEY 값
public static final String AUTHORIZATION_HEADER = "Authorization";
// 사용자 권한 값의 KEY
public static final String AUTHORIZATION_KEY = "auth";
// Token 식별자
public static final String BEARER_PREFIX = "Bearer ";
// 토큰 만료시간
private final long TOKEN_TIME = 60 * 60 * 1000L; // 60분
private final SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
//@3 apploication-secret의 property 읽어오기
@Value("${jwt.secret.key}") // Base64 Encode 한 SecretKey
private String secretKey;
private Key key;
@PostConstruct //@4 초기화
public void init() {
key = Keys.hmacShaKeyFor(Base64.getDecoder().decode(secretKey)); //@5 비밀키
}
//@6 토큰 생성
public String createToken(String email, ManagerRoleEnum role) {
Date date = new Date();
return BEARER_PREFIX +
Jwts.builder() //@7 header
.setSubject(email) //@8 payload, 이름, role, exp, iat
.claim(AUTHORIZATION_KEY, role)
.setExpiration(new Date(date.getTime() + TOKEN_TIME))
.setIssuedAt(date)
.signWith(key, signatureAlgorithm) //@9 header, signature
.compact();
}
//@10 response header에 jwt 추가
public void addJwtToHeader(String token, HttpServletResponse res) {
res.addHeader(AUTHORIZATION_HEADER, token);
}
//@11 prefix 제거
public String getJwtFromHeader(HttpServletRequest req) {
String bearerToken = req.getHeader(AUTHORIZATION_HEADER);
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) {
return bearerToken.substring(BEARER_PREFIX.length());
}
log.error("Not Found Token");
throw new JwtTokenNotFoundException();
}
//@12 토큰 검증
public boolean validateToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
return true;
} catch (SecurityException | MalformedJwtException e) {
log.error(("Invalid JWT signature, 유효하지 않는 JWT 서명 입니다."));
} catch (ExpiredJwtException e) {
log.error(("Expired JWT token, 만료된 JWT token 입니다."));
} catch (UnsupportedJwtException e) {
log.error(("Unsupported JWT token, 지원되지 않는 JWT 토큰 입니다."));
} catch (IllegalArgumentException e) {
log.error(("JWT claims is empty, 잘못된 JWT 토큰 입니다."));
}
return false;
}
//@13 토큰에서 payload 부분만 빼오기
public Claims getUserInfoFromToken(String token) {
return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody();
}
//@14 http 요청안의 jwt claim auth값을 확인하는 함수 현재는 Manager 확인 용도 밖에 없음
public boolean isManagerRequest(HttpServletRequest req, ManagerRoleEnum managerRoleEnum) {
Claims claims = ((Claims) req.getAttribute("user"));
return claims.get("auth").equals(managerRoleEnum.toString());
}
}
@1 Logging을 위해서 Slf4j 사용
@2 토큰 생성 및 발급, 검증에 필요한 데이터를 정의합니다.
- Header KEY 값, Server → Client, Client → Server에서 Http 요청과 응답을 보낼 때 Header 에 “Authorization” 이라는 키 값에 대한 Value로 Jwt 를 전송하기 위함입니다.
- 사용자 권한 값의 키, 현재 프로젝트에서 사용자의 권한으로 특정 url에 대한 접근을 제한합니다. 물론 서비스 단에서 로그인한 유저의 email로 조회하여 사용자의 권한(ex : admin, user, manger)을 조회할 수도 있지만, DB에 접근하는 비용이 들기 때문에, jwt의 장점을 살리지 못한 구현입니다. 우리는 토큰에 직접 사용자의 권한을 포함하여 토큰만으로 역할에 따른 접근제어를 하도록 하겠습니다. 전달되는 방식은 “auth” : “ROLE_ADMIN” 과 같습니다.
- Token 식별자, 우리는 jwt 방식으로 암호화된 jwt 토큰으로 사용자 인증 처리를 하고 있어서 “클라이언트가 서버에 자원에 접근할 권한을 얻기 위해 제출하는 인증 토큰” 이라는 의미인 Bearer 를 접두사로 붙여서 주고받습니다. 다른 종류의 토큰으로는 Basic, Digest 등이 있습니다. Basic Authentication: 인코딩된 사용자 이름과 비밀번호를 기반으로 하는 간단한 형태의 인증. Digest Authentication: 비밀번호를 전송하기 전에 비밀번호의 해시값과 함께 몇 가지 다른 정보를 사용하여 보안을 강화한 형태의 인증
- TOKEN_TIME : 해당 토큰이 유효한 시간을 설정합니다. ms 단위로, 1000 부터 1초가 됩니다.
- SignatureAlgorithm : Jwt는 본인이(토큰이) 어떤 방식으로 암호화되어있는지 알고 있습니다.
@3 application-secret.property 파일에서 jwt secret key를 읽어오는 부분입니다. secret key를 알고있다면, 공개된 SignatureAlgorithm을 통해서 언제든지 토큰을 위조할 수 있게되므로 repo에는 올라가면 안됩니다.
아래와 같은 방식으로 application.properties를 관리하고 있는데,
.gitignore 파일에서 아래 두 줄을 추가해 local에만 국한된 property와 secret property는 repo에 포함되지 않도록 의도했습니다.( 사실 사진상에서는 커밋이 아주 살짝 꼬여서 local은 원격 repo에 올라가있습니다.)
application-local.properties
application-secret.properties
아래는 application-secret.properties 파일 내용입니다.(당연히 제 실제 secret key와 다릅니다.)
jwt.secret.key=7Iqk7YyM66W07YOA7L2U65Sp7YG065+9U3ByaW5n6rCV7J2Y7Yqc7YSw7LWc7JuQ67mI7J6F64u
또한 위의 secret property가 application property에 포함되려면 application.properties 파일에 아래와 같이 추가해야합니다.
spring.profiles.include=local, secret
local property는 왜 추가되었는 지는 이후에 다룹니다.
@4 secret key로 사용될 Key 객체입니다.
@5 현재 비밀키는 application-secret.properties 파일에서도 확인할 수 있듯이 Base64로 encoding되어있습니다. 따라서 Base64.getDecoder().decode(secretKey) 코드로 인코딩된 문자열에서 원래의 byte배열로 변환(디코딩)합니다. 그후 이 비밀키를 HMAC(Hash based Message AuthticationCode)가 사용하여 서명을 생성하는 키를 설정합니다.
@6 대망의 토큰 생성 로직입니다.
//@6 토큰 생성
public String createToken(String email, ManagerRoleEnum role) {
Date date = new Date();
return BEARER_PREFIX +
Jwts.builder() //@7 header
.setSubject(email) //@8 payload, 이름, role, exp, iat
.claim(AUTHORIZATION_KEY, role)
.setExpiration(new Date(date.getTime() + TOKEN_TIME))
.setIssuedAt(date)
.signWith(key, signatureAlgorithm) //@9 header, signature
.compact();
}
토큰에 포함하고싶은 사용자의 정보를 매개변수로 받고 있습니다. 이는 이후 코드 안에 있는 @8 payload에 추가됩니다.
Jwts.builder()를 호출하기 이전에 BEARER_PREFIX 접두사를 붙여주고 있습니다. 이에 따라 서버단에서 요청받은 현재 토큰이 Jwt토큰이며, 인증 및 인가처리를 원한다고 알 수 있습니다.
@7 JwtBuilder의 구현체인 DefaultJwtBuilder 객체를 불러옵니다.
우리는 Header 부분에 따로 값을 지정해주지 않고 있기 때문에 Header라는 클래스에 정의된 Defalut값인 JWT_TYPE, TYPE 문자열을 사용하게됩니다.
찾아간 경로는 파일 탭에 있는 순서대로 입니다.
jwt 헤더 부분은 아래와 같습니다.
{
"typ" : "JWT",
"alg" : "HS256"
}
따라서 DefaultJwtBuilder를 사용하기만 하면 위의 내용에서"typ" : "JWT" 이 포함되어있다는 것을 이해할 수 있습니다.
@8 payload, 토큰의 내용 부분을 담당합니다, 순서대로 유저 이름(이메일이 될 수도 있음), 권한, 만료시간, 발급일을 포함시켰습니다.
@9 @7에서 설명한 jwt 토큰 헤더에 포함될 이 토큰이 암호화된 알고리즘과, 아까 byte배열로 디코딩된 배열로 생성한 비밀키를 전달해주고 있습니다.
JwtUtil 코드 다시보기
//@10 response header에 jwt 추가
public void addJwtToHeader(String token, HttpServletResponse res) {
res.addHeader(AUTHORIZATION_HEADER, token);
}
//@11 prefix 제거
public String getJwtFromHeader(HttpServletRequest req) {
String bearerToken = req.getHeader(AUTHORIZATION_HEADER);
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) {
return bearerToken.substring(BEARER_PREFIX.length());
}
log.error("Not Found Token");
throw new JwtTokenNotFoundException();
}
//@12 토큰 검증
public boolean validateToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
return true;
} catch (SecurityException | MalformedJwtException e) {
log.error(("Invalid JWT signature, 유효하지 않는 JWT 서명 입니다."));
} catch (ExpiredJwtException e) {
log.error(("Expired JWT token, 만료된 JWT token 입니다."));
} catch (UnsupportedJwtException e) {
log.error(("Unsupported JWT token, 지원되지 않는 JWT 토큰 입니다."));
} catch (IllegalArgumentException e) {
log.error(("JWT claims is empty, 잘못된 JWT 토큰 입니다."));
}
return false;
}
//@13 토큰에서 payload 부분만 빼오기
public Claims getUserInfoFromToken(String token) {
return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody();
}
//@14 http 요청안의 jwt claim auth값을 확인하는 함수 현재는 Manager 확인 용도 밖에 없음
public boolean isManagerRequest(HttpServletRequest req, ManagerRoleEnum managerRoleEnum) {
Claims claims = ((Claims) req.getAttribute("user"));
return claims.get("auth").equals(managerRoleEnum.toString());
}
@10 server → client 응답시 response header에 jwt토큰을 추가하는 함수입니다. 로그인에 성공한 유저에게 수행되어야 할 로직입니다.
@11 이전에 토큰 앞에 붙여둔 prefix, 즉 “bearer “ 문자열을 제거하여 순수한 토큰 부분만 추출하고, 추출되지 못한 경우 Request header에서 “Authorization” 키에 value 값으로 jwt토큰이 아닌 다른 토큰이 있다는 얘기가 되고 현재 서버에서 발급해주는 토큰은 jwt토큰이 유일하기 때문에 예외처리를 진행합니다.
@12 토큰의 유효성을 검증하는 로직으로, 직접 구현하지 않고, Jwts.parserBuilder를 이용합니다.
우리의 비밀키를 전달하여 해당 키로 전달받은 토큰의 header와 payload를 암호화한 후, 같이 전달받은 signature와 방금 암호화하여 생성된 signature 값을 비교합니다.
서버에서 줬던 header와 payload 그대로라면, 언제든 다시 secret key로 암호화해도 같은 signature가 나와야한다는 논리 입니다.
parserBuilder에 secretkey를 전달하여 build한 DefaultJwtParserBuilder에서 parseClaimsJws(token) 메서드를 호출함에 따른 예외는 아래의 5가지가 있고.
- SecurityException,
- MalformedJwtException
- ExpriedJwtException
- UnsupportedJwtException
- IllegalArgumentException
그에 따라 서버에서 발생한 예외에 따른 로직을 따로 수행해야합니다.(현재 없음)
@13 토큰이 @12에서 유효성 검증을 통과했다면, 이후 token에 payload에 포함된 부분을 추출하여 인가 로직에 사용할 수 있습니다.
@14 토큰에서 payload에 포함된 부분을 모두 불러오는 것 보다, 한 부분에만 관심이 있을 경우(ex : auth) 유용한 함수입니다. 애초에 http request안에서 auth값을 확인합니다.
WebMvcConfig
다음으로, 우리는 Interceptor를 사용하여 HttpRequest에서 요청을 가로채서 먼저 요청 내의 “Authorization” 헤더에 jwt 토큰이 있는지, 유효한지를 검사할 작정입니다.
따라서 Web 요청에 Formatter, ResolveHandler, Interceptor 등을 추가하는 설정을 할 수 있도록 하는 WebMvcConfigurer 인터페이스의 구현체를 구현하겠습니다.
@RequiredArgsConstructor
@Configuration //@1 Configuration 설정
public class WebMvcConfig implements WebMvcConfigurer {
@Autowired
private final JwtUtil jwtUtil;
@Value("${spring.local.url}") //@2 서버 주소
private static String URI;
@Override
public void addInterceptors(InterceptorRegistry registry) { //@3 인터셉터 정의
registry.addInterceptor(jwtTokenInterceptor())
.excludePathPatterns(URI + "/swagger-resources/**", URI + "/swagger-ui/**", URI + "/v3/api-docs", URI + "/api-docs/**")
.excludePathPatterns("/swagger-resources/**", "/swagger-ui/**", "/v3/api-docs", "/api-docs/**")
.excludePathPatterns("/signUp", "/signIn", "/error/**", "/reissue")
.addPathPatterns("/**");
}
@Bean
public JwtInterceptor jwtTokenInterceptor(){ // @4 JwtInterceptor 빈 등록
return new JwtInterceptor(jwtUtil);
}
}
@1 @Configuration 어노테이션이 붙은 클래스는 스프링이 스프링 설정 클래스임을 알 수 있도록 합니다. 애플리케이션의 구성 정보와 빈 생성에 대한 부분이 주를 이룹니다.
아까 앞으로 열심히 구현할 JwtInterceptor 클래스를 자유롭게 사용하려면, 역시 스프링의 IoC 컨테이너에 빈으로 JwtUtil이 등록될 수 있어야합니다. 빈 등록은 @4 에서 이루어지고 있습니다.
( 매끄러운 설명을 위해 어쩔 수 없이 아직 구현되지 않은 JwtInterceptor에 대해서 다루는 중입니다.)
@2 서버 주소를 가져옵니다. 현재는 로컬에서 스프링 앱을 실행하므로 localhost가 주소가 될 것입니다.
application-local.properties 파일에 아래와 같이 작성되어있습니다.
spring.local.url = localhost:8080
@3 InterceptorRegistry에 앞으로 구현할 JwtInterceptor를 추가하는 부분입니다.
excludePathPatterns 에 Intercept당하지 않았으면 하는 요청 url 추가,
addPathPatterns ********에 Intercept 당했으면 하는 요청 url을 추가하면 됩니다.
프로젝트 내에 swagger를 사용 중이므로 swagger가 jwt토큰 인증 요구를 당하지 않기 위해서 swagger 관련 요청은 jwt 인증에서 제외했습니다.
JwtInterceptor
이제 JwtInterceptor가 추가되는 로직까지는 되어있는데, 막상 Interceptor가 요청을 뺏어가서 뭘 하진 않습니다. 이제 jwt 인증이 수행되는 부분에 대한 로직을 작성하겠습니다.
@RequiredArgsConstructor
public class JwtInterceptor implements HandlerInterceptor { @1 상속
@Autowired
private final JwtUtil jwtUtil;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
HandlerMethod handlerMethod = (HandlerMethod) handler;
Method method = handlerMethod.getMethod();
log.info("method : " + method);
//@2 토큰 인증 로직
if (method.isAnnotationPresent(JwtRequired.class) || handlerMethod.getBeanType().isAnnotationPresent(JwtRequired.class)) {
String token = jwtUtil.getJwtFromHeader(request); //@3 token 가져오기
if(!jwtUtil.validateToken(token)){
return false;
}
//@4 claim 담기
Claims claims = jwtUtil.getUserInfoFromToken(token);
log.info("claims : " + claims);
request.setAttribute("user", claims);
}
return true;
}
}
@1 HandlerInterceptor의 구현체인 JwtInterceptor입니다. 구현하면서 preHandle, postHandler, afterCompletion 등의 로직을 구현할 수 있지만, 현재는 preHandle에서 요청 객체를 다루기 전 jwt 인증 로직을 수행하는 것이 목적으로 preHandle 만 구현합니다.
@2 토큰 인증에서 method는, 현재 요청 핸들러가 알아낸 사용자가 원하는 메소드(api) 입니다. 즉, Controller에서 정의되어있는 요청 함수를 말하는데, 이 함수에 JwtRequired 어노테이션이 붙어있으면 토큰 인증 로직을 수행하고, 아니면 그냥 Intercept 했던 요청을 돌려줍니다.
이전에 WebMvcConfig에서 로그인, 회원가입 페이지는 애초에 JwtInterceptor가 http 요청을 뺏어가지도 않는 것이고, 현재는 Interceptor가 http요청을 가로채와서 요청이 원하는 method에 JwtRequired 어노테이션 유무를 확인하는 것입니다.
흔한 사이트에서 회원가입 및 로그인 전에 상품을 확인할 수 있는 이유가 이때문이라고 볼 수 있습니다.
@3 jwtutil클래스를 활용해서 토큰의 접두사를 뗍니다, 이 과정에서 요청 헤더에 포함된 토큰의 접두사가 bearer가 아니였다면 getJwtFromHeader 에서 예외가 발생합니다.
꺼내온 토큰의 유효성 검증 후 유효하지않다면, 예외와 함께 false를 return하여 요청을 더이상 진행시키지 않습니다.
유효한 토큰이였다면, httprequest에 사용자 정보를 담아줍니다. 이렇게 되면 이후에 작은 비용으로 요청한 user에 대한 정보를 확인할 수 있습니다.
JwtRequired
그러면 JwtRequired는 뭘까? 내부적으로 기능은 부여되지 않은 식별용 어노테이션입니다.
@Target({ElementType.METHOD, ElementType.TYPE}) //@1
@Retention(RetentionPolicy.RUNTIME) //@2
public @interface JwtRequired {
}
@1 메서드와 클래스와 같은 타입에 이 어노테이션을 붙일 수 있고,
@2 리플렉션을 사용하여 런타임에 어노테이션 정보를 읽을 수 있도록, 런타임때까지 해당 어노테이션이 유지되어야한다는 것을 명시했습니다.
로그와 api요청으로 Interceptor 테스트
@JwtRequired 어노테이션이 붙은 메소드, 붙지 않은 메소드가 하나씩 필요합니다. 회원가입, 로그인은 jwt인증이 필요하지 않으므로 @JwtRequired 어노테이션이 붙지 않은 메소드에 대한 테스트에 포함되어있다고 생각하고,
간단한 Get Method에 @JwtRequired 어노테이션을 붙였습니다.
@JwtRequired
@ApiDocument
@Operation(summary = "test", description = "test")
@GetMapping("/lecture/{lectureId}")
public LectureResponseDto findLecture(@PathVariable Long lectureId) {
return lectureService.findLecture(lectureId);
}
테스트는 postman에서 진행하였습니다. Entity, Dto Controller에 대한 부분은 범위를 벗어나기에 최하단에 코드만 첨부합니다.
테스트시 출력되는 콘솔 로그
회원가입 성공
JwtInterceptor : method : public void com.sbl.sbl.controller.SignController.signup(com.sparta.adminserver.dto.SignUpRequestDto)
로그인 성공
JwtInterceptor : method : public void com.sbl.sbl.controller.SignController.signin(com.sparta.adminserver.dto.SignInRequestDto,jakarta.servlet.http.HttpServletResponse)
토큰을 헤더에 추가하지 않은 채 JwtRequired method에 대한 요청시
JwtInterceptor : method : public java.util.List com.sparta.adminserver.controller.LectureController.findLectureByTutor(java.lang.Long)
ERROR JwtUtil : Not Found Token
(이후 발급받은 토큰을 같이 요청합니다.)
JwtRequired method에 대한 요청시
JwtInterceptor : method : public java.util.List com.sparta.adminserver.controller.LectureController.findLectureByTutor(java.lang.Long)
JwtInterceptor : claims : {sub=s@s.com, auth=MANAGER, exp=1700203757, iat=1700200157}
jwt 토큰을 임의로 수정한 후 요청시
method : public java.util.List com.sparta.adminserver.controller.LectureController.findLectureByTutor(java.lang.Long)
ERROR JwtUtil : Invalid JWT signature, 유효하지 않는 JWT 서명 입니다.
Entity, Dto, Controller
@Controller
@RequiredArgsConstructor
public class SignController {
@Autowired
private final JwtUtil jwtUtil;
@Autowired
private final SignService signService;
@ApiDocument
@Operation(summary = "signup", description = "회원 가입")
//form 형식 데이터를 받기 위해 @ModelAttribute 사용
// Valid 에서 걸릴 시 MethodArgumentNotValidException발생
@PostMapping("/signup")
public void signup(@ModelAttribute @Valid SignUpRequestDto req) {
signService.signup(req);
}
@ApiDocument
@Operation(summary = "signin", description = "로그인")
@PostMapping("/signin")
public void signin(@ModelAttribute @Valid SignInRequestDto req, HttpServletResponse res) {
try {
signService.signin(req, res);
} catch (Exception e) {
throw new RuntimeException("로그인 실패");
}
}
}
@Getter
@AllArgsConstructor(access = AccessLevel.PROTECTED)
public class SignUpRequestDto {
@Email
private String email;
private String password;
@MyEnum(enumClass = ManagerRoleEnum.class)
private String role;
@MyEnum(enumClass = UserDepartmentEnum.class)
private String department;
public User toEntity() {
User user = new User();
user.setEmail(this.email);
user.setPassword(this.password);
user.setRole(this.role);
user.setDepartment(this.department);
return user;
}
}
@Getter
@Setter
@Entity
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String email;
@Column(nullable = false)
private String password;
@Column(nullable = false)
private String department;
@Column(nullable = false)
private String role;
}