eggrok
springboot에서 jwt 구현 본문
jwt를 이용한 인증이 필요해서 샘플 코드 작성하였음.
* springboot, jwt, jpa, mybatis 사용.
1. springboot에 jwt를 사용하기 위한 디펜던시 추가.
build.gradle에 하단의 내용 추가.
// jwt 라이브러리
implementation 'io.jsonwebtoken:jjwt:0.9.1'
// jpa 라이브러리
implementation group: 'org.springframework.boot', name: 'spring-boot-starter-data-jpa', version: '2.6.7'
2. Mybatis를 위한 config파일.
* MybatisConfig.java
package eggrok.api.configuration;
import javax.sql.DataSource;
import org.apache.ibatis.session.SqlSessionFactory;
import org.mybatis.spring.SqlSessionFactoryBean;
import org.mybatis.spring.SqlSessionTemplate;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.env.Environment;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor
@Configuration
@MapperScan(basePackages = {"eggrok.api.dao"}, sqlSessionFactoryRef = "sqlSessionFactory", sqlSessionTemplateRef = "sqlSessionTemplate")
public class MybatisConfig2 {
private final Environment env;
@Bean(destroyMethod = "close")
public DataSource dataSource() {
HikariConfig config = new HikariConfig();
config.setJdbcUrl(env.getProperty("spring.datasource.url"));
config.setDriverClassName(env.getProperty("spring.datasource.driver-class-name"));
config.setUsername(env.getProperty("spring.datasource.username"));
config.setPassword(env.getProperty("spring.datasource.password"));
config.addDataSourceProperty("maximumPoolSize", "20");
config.addDataSourceProperty("cachePrepStmts", "true");
config.addDataSourceProperty("prepStmtCacheSize", "250");
config.addDataSourceProperty("prepStmtCacheSqlLimit", "2048");
return new HikariDataSource(config);
}
@Bean
public SqlSessionFactory sqlSessionFactory(@Qualifier("dataSource") DataSource dataSource) throws Exception {
SqlSessionFactoryBean sqlSessionFactoryBean = new SqlSessionFactoryBean();
sqlSessionFactoryBean.setDataSource(dataSource);
// mapper.xml 의 resultType 패키지 주소 생략
sqlSessionFactoryBean.setTypeAliasesPackage("eggrok.api.model");
// mybatis 설정 파일 세팅
sqlSessionFactoryBean.setConfigLocation(new PathMatchingResourcePatternResolver().getResource("classpath:mybatis/mybatis-config.xml"));
// mapper.xml 위치 패키지 주소
sqlSessionFactoryBean.setMapperLocations(new PathMatchingResourcePatternResolver().getResources("classpath:eggrok/api/dao/*.xml"));
return sqlSessionFactoryBean.getObject();
}
@Bean
public SqlSessionTemplate sqlSessionTemplate(@Qualifier("sqlSessionFactory") SqlSessionFactory sqlSessionFactory) throws Exception {
return new SqlSessionTemplate(sqlSessionFactory);
}
}
3. JwtUtils.java
package eggrok.api.util;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import javax.servlet.http.HttpServletRequest;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import lombok.extern.slf4j.Slf4j;
import eggrok.api.model.auth.JwtResponseDTO;
import eggrok.api.model.auth.UserEntity;
import eggrok.api.model.auth.UserInfoDTO;
@Slf4j
public class JwtUtil {
/* jwt의 body안의 내용을 암호화 하기위한 AES key */
public static final String AES_KEY = "84720989750123457466639208678901";
/* jwt signature를 생성하기 위한 key */
private static final String JWT_SECRET_KEY = "za8d74bq-1358-48a4-b549-b926f5d77c9e";
private static final long ONE_MIN = 60 * 1000L;
private static final long accessExpireTime = ONE_MIN; // 1분
private static final long refreshExpireTime = ONE_MIN * 2; // 2분
private static final String USER_DATA_KEY = "USER_DATA";
/**
* userInfo를 이용해서, jwt access토큰, refresh토큰, deviceUUID를 생성
*
* @param userInfo
* @return
* @throws Exception
*/
public static JwtResponseDTO getJwtDTO(UserEntity userEntity) throws Exception {
UserInfoDTO userInfo = new UserInfoDTO(userEntity);
/* 인증을 시도한 디바이스에게 발급할 uuid생성. */
String accessTkn = createAccessToken(userInfo);
Map<String, String> refreshMap = createRefreshToken(userInfo);
String refreshTkn = refreshMap.get("refreshToken");
JwtResponseDTO jwtDTO = new JwtResponseDTO();
jwtDTO.setAccessToken(accessTkn);
jwtDTO.setRefreshToken(refreshTkn);
jwtDTO.setDeviceUUID(userInfo.getDeviceUUID());
return jwtDTO;
}
/**
* accessToken 생성
*
* @param userInfo
* @return
* @throws Exception
*/
public static String createAccessToken(UserInfoDTO userInfo) throws Exception {
log.info("userInfo : {}", userInfo);
Map<String, Object> headers = new HashMap<>();
headers.put("type", "token");
Map<String, Object> payloads = new HashMap<>();
payloads.put(USER_DATA_KEY, JwtUtil.getEncUserInfo(userInfo));
Date expiration = new Date();
expiration.setTime(expiration.getTime() + accessExpireTime);
String jwt = Jwts.builder().setHeader(headers).setClaims(payloads).setSubject("user").setExpiration(expiration)
.signWith(SignatureAlgorithm.HS256, JWT_SECRET_KEY).compact();
return jwt;
}
/**
* refreshToken 생성
*
* @param userEmail
* @return
* @throws Exception
*/
public static Map<String, String> createRefreshToken(UserInfoDTO userInfo) throws Exception {
Map<String, Object> headers = new HashMap<>();
headers.put("type", "token");
Map<String, Object> payloads = new HashMap<>();
payloads.put("userData", JwtUtil.getEncUserInfo(userInfo));
Date expiration = new Date();
expiration.setTime(expiration.getTime() + refreshExpireTime);
SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.KOREA);
String refreshTokenExpirationAt = simpleDateFormat.format(expiration);
String jwt = Jwts.builder().setHeader(headers).setClaims(payloads).setSubject("user").setExpiration(expiration)
.signWith(SignatureAlgorithm.HS256, JWT_SECRET_KEY).compact();
Map<String, String> result = new HashMap<>();
result.put("refreshToken", jwt);
result.put("refreshTokenExpirationAt", refreshTokenExpirationAt);
System.err.println("createRefreshToken(). " + result.toString());
return result;
}
/**
* 토큰에서 UserInfoDTO 추출
*
* @param token
* @return
* @throws Exception
*/
public static UserInfoDTO getUserInfo(String token) throws Exception {
System.err.println("getUserInfo(). token : " + token);
String encUserData = (String) Jwts.parser().setSigningKey(JWT_SECRET_KEY).parseClaimsJws(token).getBody()
.get(USER_DATA_KEY);
System.err.println("getUserInfo(). encUserData : " + encUserData);
return getDecUserInfo(encUserData);
}
public static String resolveToken(HttpServletRequest request) {
String token = request.getHeader("X-AUTH-TOKEN");
return token;
}
/**
* jwt의 유효성 체크. expire date , signature
*
* @deprecated : token을 파싱할때 유효성 체크후 에러를 던지므로, 굳이 미리 할필요가 없음.
*
* @param request
* @param authToken
*/
@Deprecated
private static void validateJwtToken(String authToken) {
Jwts.parser().setSigningKey(JWT_SECRET_KEY).parseClaimsJws(authToken);
// try {
//
// } catch (MalformedJwtException e) {
// request.setAttribute("exception", "MalformedJwtException");
// } catch (ExpiredJwtException e) {
// request.setAttribute("exception", "ExpiredJwtException");
// } catch (UnsupportedJwtException e) {
// request.setAttribute("exception", "UnsupportedJwtException");
// } catch (IllegalArgumentException e) {
// request.setAttribute("exception", "IllegalArgumentException");
// }
}
/**
* UserInfoDTO를 json문자열로 파싱후, AES로 인크립트해서 리턴
*
* @return
* @throws Exception
*/
public static String getEncUserInfo(UserInfoDTO userInfo) throws Exception {
ObjectMapper mapper = new ObjectMapper();
String jsonStr = mapper.writeValueAsString(userInfo);
return AES256Util.encrypt(jsonStr, AES_KEY).toString();
}
/**
* AES로 인크립트된 json문자열을 디크립트 하고 UserInfoDTO로 변환 후, 리턴
*
* @param encStr
* @return
* @throws Exception
*/
public static UserInfoDTO getDecUserInfo(String encStr) throws Exception {
String decStr = AES256Util.decrypt(encStr, AES_KEY);
ObjectMapper mapper = new ObjectMapper();
return mapper.readValue(decStr, UserInfoDTO.class);
}
}
4. UserInfoDTO.java
package eggrok.api.model.auth;
import java.util.UUID;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 사용자 정보 DTO
*
* @author eggrok
*
*/
@Data
@NoArgsConstructor
public class UserInfoDTO {
public UserInfoDTO(UserEntity userEntity) {
this.no = userEntity.getNo();
this.email = userEntity.getEmail();
this.deviceUUID = no + "-" + UUID.randomUUID().toString();
}
/**
* 사용자 번호
*/
private Long no;
/**
* 사용자 이름
*/
private String name;
/**
* 사용자 이메일
*/
private String email;
/**
* 인증을 요청한 사용자의 device uuid
*/
private String deviceUUID;
}
5. AuthController.java
package eggrok.api.controller;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseCookie;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.ModelAndView;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import eggrok.api.model.auth.JwtResponseDTO;
import eggrok.api.model.auth.LoginDTO;
import eggrok.api.service.AuthService;
@Slf4j
@Tag(name = "AuthController", description = "AuthController")
@RequestMapping("/auth")
@RequiredArgsConstructor
@RestController
public class AuthController {
private final AuthService authService;
/**
* 테스르를 위한 tymeleaf html 페이지 호출
* @return
*/
@RequestMapping("/index")
public ModelAndView index() {
return new ModelAndView("index");
}
@Tag(name = "로그인 api", description = "로그인 api")
@ApiResponses(value = { @ApiResponse(responseCode = "200", description = "") })
@PostMapping(value = "/login", produces = "application/json")
@ResponseBody
public ResponseEntity<JwtResponseDTO> login2(@RequestBody LoginDTO loginDTO, HttpServletRequest request,
HttpServletResponse response) throws Exception {
/* jwt 생성 */
JwtResponseDTO jwtDTO = authService.executeLoginAndGetJwtResponse(loginDTO);
/* 웹브라우저의 로그인 요청인 경우, response에 device_uuid 쿠키 추가. */
if(isWebBrower(request)) {
ResponseCookie resCookie = ResponseCookie.from("JWT_DEVICE_UUID", jwtDTO.getDeviceUUID())
.secure(true).httpOnly(true).path("/").maxAge(7 * 24 * 60 * 60) // expires in 7 days
.sameSite("none")
.build();
response.addHeader(HttpHeaders.SET_COOKIE, resCookie.toString());
/* response header에 직접 쿠키 문자열 셋팅 */
// String fingerPrintCookie = "JWT_DEVICE_UUID=" + jwtDTO.getDeviceUUID() + "; SameSfite=Strict; HttpOnly; Secure";
// response.addHeader("Set-Cookie", fingerPrintCookie); // TODO : 쿠키 셋팅 옵션이 제대로 동작하는지 확인 필요.
/* Cookie를 이용한 쿠키 셋팅 */
// Cookie cookie = new Cookie("JWT_DEVICE_UUID", jwtDTO.getDeviceUUID());
// cookie.setMaxAge(7 * 24 * 60 * 60); // expires in 7 days
// cookie.setSecure(true);
// cookie.setHttpOnly(true);
// cookie.setPath("/");
// response.addCookie(cookie);
jwtDTO.setDeviceUUID(null); // 브라우저 요청인경우, deviceUUID 값을 제거.
}
ResponseEntity<JwtResponseDTO> res = new ResponseEntity<JwtResponseDTO>(jwtDTO, HttpStatus.OK);
return res;
}
/**
* 웹브라우저 요청인지 체크
* @param request
* @return
*/
private boolean isWebBrower(HttpServletRequest request) {
// TODO : 웹브라우저의 요청인지 체크하는 로직 구현필요.
return true;
}
}
6. AuthService.java
package eggrok.api.service;
import javax.transaction.Transactional;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import eggrok.api.model.auth.JwtResponseDTO;
import eggrok.api.model.auth.LoginDTO;
import eggrok.api.model.auth.LoginHistEntity;
import eggrok.api.model.auth.UserEntity;
import eggrok.api.repository.UserLoginHistRepository;
import eggrok.api.repository.UserRepository;
import eggrok.api.util.JwtUtil;
@Slf4j
@RequiredArgsConstructor
@Service
public class AuthService {
private final UserRepository userRepo;
private final UserLoginHistRepository loginHistRepo;
/**
* login 처리와 jwt 리턴
*
* @param loginDTO
* @return
*/
@Transactional
public JwtResponseDTO executeLoginAndGetJwtResponse(LoginDTO loginDTO) throws Exception {
/* 회원정보 조회 */
UserEntity userEntity = userRepo.findByEmail(loginDTO.getUserEmail());
/* 회원이 없으면 자동 등록 : 임시로 추가한 로직 */
if (userEntity == null || userEntity.getNo() < 1) {
userEntity = new UserEntity();
userEntity.setEmail(loginDTO.getUserEmail());
userEntity = userRepo.save(userEntity);
}
/* jwt 생성 */
JwtResponseDTO jwtDTO = JwtUtil.getJwtDTO(userEntity);
/* 로그인 이력 등록 */
LoginHistEntity histEntity = new LoginHistEntity();
histEntity.setRefreshToken(jwtDTO.getRefreshToken());
histEntity.setUserNo(userEntity.getNo());
loginHistRepo.save(histEntity);
return jwtDTO;
}
}
7. LoginCheckInterceptor.java
package eggrok.api.interceptor;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.apache.commons.lang3.StringUtils;
import org.springframework.http.HttpMethod;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import eggrok.api.model.auth.UserInfoDTO;
import eggrok.api.util.JwtUtil;
/**
* 인증 체크
*
* @author eggrok
*
*/
@Slf4j
@RequiredArgsConstructor
@Component
public class LoginCheckInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object controller)
throws Exception {
log.info(">> LoginCheckInterceptor preHandle Start<<");
log.info("request : {}", request);
/**
* #1. Filter가 아닌 CorsRegistry를 사용하려면 Options인 경우 return false가 아닌 true로 해야 한다.
*/
if (HttpMethod.OPTIONS.name().equals(request.getMethod()))
return true;
String accessToken = JwtUtil.resolveToken(request);
if (StringUtils.isBlank(accessToken)) {
throw new RuntimeException("accessToken is null");
}
UserInfoDTO userInfo = JwtUtil.getUserInfo(accessToken);
/* request의 cookie에서 JWT_DEVICE_UUID 조회 */
String jwtDeviceUUID = null;
for(Cookie cookie : request.getCookies()) {
if("JWT_DEVICE_UUID".equals(cookie.getName())) {
jwtDeviceUUID = cookie.getValue();
break;
}
}
/* access token의 deviceUUID와 client로부터 받은 deviceUUID가 다르면 에러 발생. */
if(!userInfo.getDeviceUUID().equals(jwtDeviceUUID)) {
log.error("userInfoDTO : {}, cookie[JWT_DEVICE_UUID] : {}", userInfo, jwtDeviceUUID);
throw new RuntimeException("jwt deviceUUID is not valild.");
}
request.setAttribute("jwtUserInfo", userInfo);
log.info("userInfoDTO : {}", userInfo);
log.info("accessToken : {}", accessToken);
log.info("jwt device uuid : {}", jwtDeviceUUID);
/**
* 인증 체크
*/
log.info(">> LoginCheckInterceptor preHandle End<<");
return true;
}
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler,
ModelAndView modelAndView) throws Exception {
}
}
기본적인 jwt의 구현에 대한 샘플코드 작성.
* 필수적인 코드만 추가함.
* jwt 토큰 탈취에 대한 보안 처리로, device_uuid를 httpOnly 쿠키로 생성.
* jwt body의 내용에 대한 보안 처리로, AES로 암호화
* jwt의 인증에는 로그아웃을 구현하는것이 쉽지 않다. 서버의 저장소에서는 따로 인증정보를 저장하지 않고, token의 정보로만 인증을 처리하기 때문이다.
로그아웃을 구현하기 위해, 로그아웃 요청이오면, client의 device_uuid를 삭제하도록 처리하여, 인증이 불가하도록 처리하면됨.
* token의 refresh요청에 대한 api는 따로 구현하지 않았음.
참고
https://wnwngus.tistory.com/65
SpringBoot 프로젝트에 JWT 토큰 인증 방식 구현하기
Refresh Token + Access Token + BlackList 전략으로 로그인 인증 구현하기 저는 이전에 진행 중이던 프로젝트에서 JWT 토큰 인증 방식을 선택해 프로젝트를 진행하였습니다. 프로젝트를 진행하면서 팀원들
wnwngus.tistory.com
https://velog.io/@cham/JWT-JWT-%EB%B3%B4%EC%95%88%EC%97%90-%EB%8C%80%ED%95%9C-%EA%B3%A0%EC%B0%B0
[JWT] JWT 보안에 대한 고찰
0. 들어가기에 앞서 프로젝트에 JWT를 적용하며 고민했던 것들에 대해 정리합니다. 다양한 의견들 남겨주시면 감사하겠습니다. 1. Access token과 Refresh token Refresh Token에 대해 조금만 확인해보면 DB에
velog.io
'programming' 카테고리의 다른 글
[java] TDD , 테스트 주도 개발 (0) | 2012.05.04 |
---|---|
[web]톰캣 설치후, 포트 번호 바꾸기와 80포트와 8080 포트 (0) | 2012.04.30 |
[web] WTP 에서 실제 tomcat 서버로 deploy 하기. (0) | 2012.04.30 |
[컴퓨터] 프레임 워크란?? (0) | 2012.04.24 |
[java] 이클립스 , 프로젝트의 ! 경고 및..서버 클린방법 (0) | 2012.04.23 |