최종적으로 JWT Token 발급, Login Logic을 구현하기 위한 참조 관계는 위와 같다.
dependencies {
...
implementation 'org.springframework.security:spring-security-crypto'
...
}
Password Encrtyption
을 구현하기 위해 spring-security-crypto
의존성 포함
package com.hearus.hearusspring.common.security;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
@Configuration
public class CommonConfig {
@Bean
public PasswordEncoder passwordEncoder(){
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
}
이후 CommonConfig
class를 선언하여 passwordEncoder를 사용할 수 있게 한다.
public class UserDTO {
...
public UserEntity toEntitiy(PasswordEncoder passwordEncoder){
String stringLecture = null;
if(!userSavedLectures.isEmpty())
stringLecture = String.join(",", userSavedLectures);
return UserEntity.builder()
.id(userId)
.name(userName)
.email(userEmail)
.password(passwordEncoder.encode(userPassword))
.isOAuth(userIsOAuth)
.oauthType(userOAuthType)
.school(userSchool)
.major(userMajor)
.grade(userGrade)
.savedLectures(stringLecture)
.schedule(userSchedule)
.usePurpose(userUsePurpose)
.build();
}
}
UserDTO
에서 UserEntity
로 변환하는 과정에서 userPassword
을 encoding하여 변환한다.
@Service
@Transactional
public class UserHandlerImpl implements UserHandler {
UserDAO userDAO;
private final Logger LOGGER = LoggerFactory.getLogger(UserHandlerImpl.class);
@Autowired
PasswordEncoder passwordEncoder;
@Autowired
public UserHandlerImpl(UserDAO userDAO) {
this.userDAO = userDAO;
}
@Override
public CommonResponse signupUserEntitiy(UserDTO user) {
LOGGER.info("[UserHandler]-[signupUserEntitiy] UserDAO로 UserEntitiy 회원가입 요청 : {}", user.getUserEmail());
UserEntity userEntity = user.toEntitiy(passwordEncoder);
return userDAO.userSignup(userEntity);
}
}
최종적으로 @Autowired
된 PasswordEncoder를 toEntitiy
로 넘기면
아래와 같이 정상적으로 password가 encrypt되어 저장되는 것을 볼 수 있다.
이때 주의해야 하는 점은 PasswordEncoder
는 단방향 변환과 매치 여부만 확인이 가능하기 때문에 추후 로그인 로직 구현시 해당 사항에 주의하여 구현해야 한다는 것이다.
# JWT
JWT_ACCESS_SECRET=LQBmrthxoASDHoqisSEUIJ
JWT_REFRESH_SECRET=POQKSANxncbnasQAOJSDSAqsadQ
JWT Token의 Access Secret을 안전하게 관리하기 위해 application-private.properties
에 추가하고 이를 Express.js의 환경변수처럼 불러와 사용하도록 구조화한다.
dependencies {
...
// SECURITY
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.security:spring-security-crypto'
// JWT
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'
implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'
}
이후 dependencies
에 위와 같이 Security, JWT 관련 라이브러리를 추가한다.
package com.hearus.hearusspring.data.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
@Builder
@Data
@AllArgsConstructor
public class TokenDTO {
private String grantType;
private String accessToken;
private String refreshToken;
}
이후 Token 정보를 처리하기 위해 TokenDTO
를 정의한다.
@Builder
: 빌더 패턴으로 객체를 생성하기 위한 Annotation
빌더 패턴으로 객체를 생성하면 얻는 이점은 아래와 같다
1. 생성자 파라미터가 많을 경우 가독성이 좋지 않다.
2. 어떤 값을 먼저 설정하던 상관 없다.
package com.hearus.hearusspring.common.environment;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.PropertySource;
import org.springframework.core.env.Environment;
import java.security.Key;
@Slf4j
@Configuration
@RequiredArgsConstructor
@PropertySource("classpath:application-private.properties")
public class ConfigUtil {
private final Environment environment;
public String getProperty(String key){
return environment.getProperty(key);
}
}
또한 위와 같이 JWT Access Secret을 불러오기 위해 ConfigUtil
를 생성한다.
이때 @PropertySource
는 classpath로 private properties를 주입한다.
@Slf4j
: Simple Logging Facade 4 Java의 LOGGER 객체를 자동으로 생성하여주는 Annotation
package com.hearus.hearusspring.common.enumType;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
@Getter
@RequiredArgsConstructor
public enum RoleType {
USER_FREE("ROLE_FREE","무료 Tier 사용자"),
USER_PREM("ROLE_PREM","프리미엄 Tier 사용자"),
ADMIN("ROLE_ADMIN","관리자");
private final String key;
private final String title;
}
이후 사용자의 Tier를 구분하는 기능을 추후 개발하기 위하여 RoleType
enum을 생성한다.
public class UserDTO {
@Id
String userId = "";
String userName;
@Email
String userEmail;
String userPassword;
String userRole = RoleType.USER_FREE.name();
...
UserDTO
, UserEntitiy
에 userRole
를 추가하여 Enum에서 불러와 현재는 Default 값을 설정하여 준다.
package com.hearus.hearusspring.common.security;
...
@Slf4j
@Component
public class JwtTokenProvider {
private final Key accessKey;
private final Key refreshKey;
@Autowired
public JwtTokenProvider(ConfigUtil configUtil){
byte[] accessByte = Decoders.BASE64.decode(configUtil.getProperty("JWT_ACCESS_SECRET"));
byte[] refreshByte = Decoders.BASE64.decode(configUtil.getProperty("JWT_REFRESH_SECRET"));
this.accessKey = Keys.hmacShaKeyFor(accessByte);
this.refreshKey = Keys.hmacShaKeyFor(refreshByte);
}
// 유저 정보를 통해 Access Token, Refresh Token 생성하는 매소드
public TokenDTO generateToken(UserDTO userDTO) {
long now = (new Date()).getTime();
// Access Token 생성
// subject는 User의 ID
// Access Token의 유효기간은 1시간
Date accessTokenExpiresIn = new Date(now + 3600000);
String accessToken = Jwts.builder()
.setSubject(userDTO.getUserId())
.claim("role", userDTO.getUserRole())
.setExpiration(accessTokenExpiresIn)
.signWith(accessKey, SignatureAlgorithm.HS256)
.compact();
// Refresh Token 생성
// Refresh Token의 유효기간은 1일
String refreshToken = Jwts.builder()
.setExpiration(new Date(now + 86400000))
.signWith(refreshKey, SignatureAlgorithm.HS256)
.compact();
return TokenDTO.builder()
.grantType("Bearer")
.accessToken(accessToken)
.refreshToken(refreshToken)
.build();
}
// JWT 토큰을 복호화하여 정보를 꺼내는 메소드
public String getTokenInfo(String accessToken) {
// 토큰 복호화
Claims claims = parseAccessClaims(accessToken);
if (claims.get("role") == null) {
log.info("[JwtTokenProvider]-[getAuthentication] Failed : Token has no Role");
throw new RuntimeException("Token has no Role");
}
// UserId인 claim의 subject를 return
return claims.getSubject();
}
// Access Token 정보 검증
public boolean validateAccessToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(accessKey).build().parseClaimsJws(token);
return true;
} catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
log.info("[JwtTokenProvider]-[validateAccessToken] Invalid JWT Token");
} catch (ExpiredJwtException e) {
log.info("[JwtTokenProvider]-[validateAccessToken] Expired JWT Token");
} catch (UnsupportedJwtException e) {
log.info("[JwtTokenProvider]-[validateAccessToken] Unsupported JWT Token");
} catch (IllegalArgumentException e) {
log.info("[JwtTokenProvider]-[validateAccessToken] JWT claims string is empty");
}
return false;
}
public boolean validateRefreshToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(refreshKey).build().parseClaimsJws(token);
return true;
} catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
log.info("[JwtTokenProvider]-[validateRefreshToken] Invalid JWT Token");
} catch (ExpiredJwtException e) {
log.info("[JwtTokenProvider]-[validateRefreshToken] Expired JWT Token");
} catch (UnsupportedJwtException e) {
log.info("[JwtTokenProvider]-[validateRefreshToken] Unsupported JWT Token");
} catch (IllegalArgumentException e) {
log.info("[JwtTokenProvider]-[validateRefreshToken] JWT claims string is empty");
}
return false;
}
private Claims parseAccessClaims(String accessToken) {
try {
return Jwts.parserBuilder().setSigningKey(accessKey).build().parseClaimsJws(accessToken).getBody();
} catch (ExpiredJwtException e) {
return e.getClaims();
}
}
}
이후 JWT관련 기능을 담고 있는 JwtTokenProvider
클래스를 선언한다.
JwtTokenProvider()
: configUtil에서 Secret을 가져와 Key로 변환한다.
generateToken()
: UserDTO의 userId를 Claim으로 담는 Access Token과 갱신이 가능한 Refresh Token을 각각의 Secret Key로 생성하고 TokenDTO을 반환한다.
getTokenInfo()
: Claim의 subject인 userId를 토큰에서 복호화하여 반환한다.
validate...Token()
: 해당 Token이 Validate한지 여부를 반환한다.
@Component
: 다른 Class에서 Bean으로 불러와서 사용할 수 있도록 하는 Annotation, 개발자가 직접 작성한 클래스를 Bean으로 등록하고자 할 경우 사용
package com.hearus.hearusspring.config;
import com.hearus.hearusspring.common.enumType.RoleType;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
// TODO : Spring Security 설정, CSRF 등
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.securityMatcher("/api/**")
.authorizeHttpRequests(authorize -> authorize
.anyRequest().hasRole(RoleType.USER_FREE.getKey())
)
.httpBasic(Customizer.withDefaults());
return http.build();
}
}
또한 추후 Spring Security 적용을 위해 SecurityConfig
를 생성한다.
public interface UserDAO {
UserEntity getUserById(String userId);
UserEntity userLogin(UserEntity user);
CommonResponse userSignup(UserEntity user);
}
public class UserDAOImpl implements UserDAO {
...
@Override
public UserEntity getUserById(String userId){
LOGGER.info("[UserDAO]-[userLogin] UserID로 User 정보 찾기");
return userRepository.findFirstById(userId);
}
@Override
public UserEntity userLogin(UserEntity user) {
LOGGER.info("[UserDAO]-[userLogin] 로그인 시도 : {}", user.getEmail());
return userRepository.findFirstByEmailAndPassword(user.getEmail(),user.getPassword());
}
package com.hearus.hearusspring.data.handler;
import com.hearus.hearusspring.common.CommonResponse;
import com.hearus.hearusspring.data.dto.UserDTO;
public interface UserHandler {
UserDTO loginUserEntity(UserDTO user);
CommonResponse signupUserEntitiy(UserDTO user);
}
UserDAO에서 userLogin()
과 getUserById()
메소드를 구현한다.
public class UserServiceImpl implements UserService {
UserHandler userHandler;
JwtTokenProvider jwtTokenProvider;
...
@Override
public CommonResponse userLogin(UserDTO user) {
LOGGER.info("[UserService]-[userLogin] UserHandler로 로그인 요청 : {}", user.getUserEmail());
try {
UserDTO loginResult = userHandler.loginUserEntity(user);
TokenDTO loginToken = jwtTokenProvider.generateToken(loginResult);
LOGGER.info("[UserService]-[userLogin] 로그인 성공 : {}", jwtTokenProvider.getTokenInfo(loginToken.getAccessToken()));
return new CommonResponse(true, HttpStatus.OK,loginToken.toString());
}catch (Exception e){
LOGGER.info("[UserService]-[userLogin] 로그인 실패");
return new CommonResponse(false, HttpStatus.UNAUTHORIZED,"Invalid User Info");
}
}
이후 UserService에서 위와 같이 Login하는 로직을 구현하고
@RestController
@RequestMapping("/api/v1/auth")
public class UserAuthController {
private final Logger LOGGER = LoggerFactory.getLogger(UserAuthController.class);
private final UserService userService;
private CommonResponse response;
@Autowired
public UserAuthController(UserService userService) {
this.userService = userService;
}
@PostMapping(value="/login")
public ResponseEntity<CommonResponse> loginUser(@Valid @RequestBody UserDTO userDTO){
LOGGER.info("[UserAuthController]-[loginUser] API Call");
// 요구되는 데이터 존재 여부 검증
if(userDTO.getUserEmail().isEmpty() || userDTO.getUserPassword().isEmpty()){
LOGGER.info("[UserAuthController]-[loginUser] Failed : Empty Variables");
response = new CommonResponse(false,HttpStatus.BAD_REQUEST,"Empty Variables");
return ResponseEntity.status(response.getStatus()).body(response);
}
// UserService로 요청받은 UserDTO 로그인 요청
response = userService.userLogin(userDTO);
return ResponseEntity.status(response.getStatus()).body(response);
}
...
최종적으로 Controller의 loginUser
API를 구현한다.
The specified key byte array is 128 bits which is not secure enough for any JWT HMAC-SHA algorithm.
The JWT JWA Specification (RFC 7518, Section 3.2) states that keys used with HMAC-SHA algorithms MUST have a size >= 256 bits
위와 같은 오류는 HMAC-SHA
알고리즘에 필요한 Secret 값의 길이가 256비트 미만이어서 발생하는 문제로, Secret의 길이를 더 길게 설정하면 문제가 해결된다.
API를 요청했을 때 위와 같이 Forbidden
오류가 발생한다면
public class SecurityConfig {
// TODO : Spring Security 설정, CSRF 등
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// TODO : main API 구현시 Spring Security 설정
.securityMatcher("/api/v1/main")
.authorizeHttpRequests(authorize -> authorize
.anyRequest().hasRole(RoleType.USER_FREE.getKey())
)
.httpBasic(Customizer.withDefaults());
return http.build();
}
}
SecurityConfig
의 filterChain
의 securityMatcher
를 변경하여 해결한다.
@Service
public class UserDAOImpl implements UserDAO {
...
@Override
public UserEntity userLogin(UserEntity user) {
LOGGER.info("[UserDAO]-[userLogin] UserEmail로 정보 가져오기 : {}", user.getEmail());
return userRepository.findFirstByEmail(user.getEmail());
}
UserDAO에서의 UserEntitiy
의 Password는 PasswordEncoder
통해서 인코딩된 값이며, 매 인코딩시 결과가 달라지는 특성 때문에 passwordEncoder.matches()
만을 통해서 검증할 수 있다.
따라서 UserRepository
의 findFirstByEmail
를 통해 Entitiy를 이메일을 통해 받아와 UserHandler
로 반환한다.
@Service
@Transactional
public class UserHandlerImpl implements UserHandler {
...
@Override
public UserDTO loginUserEntity(UserDTO user) {
LOGGER.info("[UserHandler]-[signupUserEntitiy] UserDAO로 로그인 요청 : {}", user.getUserEmail());
UserEntity userEntity = user.toEntitiy(passwordEncoder);
if(!passwordEncoder.matches(user.getUserPassword(), userEntity.getPassword()))
return null;
return userDAO.userLogin(userEntity).toDTO();
}
이후 passwordEncoder.matches()
를 통해 로그인 요청시 받아온 원본 데이터인 UserDTO
의 Password와 DB에 저장된 UserEntity
의 Password를 비교하여 로그인 가능 여부를 판별한다.
public class UserServiceImpl implements UserService {
...
@Override
public CommonResponse userLogin(UserDTO user) {
LOGGER.info("[UserService]-[userLogin] UserHandler로 로그인 요청 : {}", user.getUserEmail());
try {
UserDTO loginResult = userHandler.loginUserEntity(user);
// 로그인 실패 여부 검증
if(loginResult == null)
return new CommonResponse(false, HttpStatus.UNAUTHORIZED,"Invalid User Info");
TokenDTO loginToken = jwtTokenProvider.generateToken(loginResult);
LOGGER.info("[UserService]-[userLogin] 로그인 성공 : {}", jwtTokenProvider.getTokenInfo(loginToken.getAccessToken()));
return new CommonResponse(true, HttpStatus.OK,loginToken.toString());
}catch (Exception e){
LOGGER.info("[UserService]-[userLogin] 로그인 도중 Exception 발생");
return new CommonResponse(false, HttpStatus.INTERNAL_SERVER_ERROR,"Internal Server Error");
}
}
또한 이후 UserService
에서 정상적으로 loginResult
가 반환된다면 jwtTokenProvider
를 통하여 Token을 생성하고 생성된 토큰을 CommonResponse
에 담아 반환한다.
public class UserEntity extends BaseEntitiy{
...
public UserDTO toDTO(){
// ','를 기준으로 split
ArrayList<String> savedLecturesList = new ArrayList<>();
if(!savedLectures.isEmpty())
savedLecturesList = Arrays.stream(
savedLectures.split(","))
.map(String::trim)
.collect(Collectors.toCollection(ArrayList::new));
또한 DTO로 변환할 때 null 여부를 판별하는 코드를 작성한다.
public class UserServiceImpl implements UserService {
UserHandler userHandler;
private final JwtTokenProvider jwtTokenProvider;
private final Logger LOGGER = LoggerFactory.getLogger(UserServiceImpl.class);
@Autowired
public UserServiceImpl(UserHandler userHandler, JwtTokenProvider jwtTokenProvider) {
this.userHandler = userHandler;
this.jwtTokenProvider = jwtTokenProvider;
}
...
이는 @Autowired
를 통해 생성자에서 JwtTokenProvider
를 선언하여 문제를 해결할 수 있다.
@Getter
@Setter
public class CommonResponse {
private boolean isSuccess;
HttpStatus status;
private String msg;
private Object object;
public CommonResponse(boolean isSuccess, String msg) {
this.isSuccess = isSuccess;
this.msg = msg;
}
public CommonResponse(boolean isSuccess, HttpStatus status, String msg) {
this.isSuccess = isSuccess;
this.status = status;
this.msg = msg;
}
public CommonResponse(boolean isSuccess, HttpStatus status, String msg, Object object) {
this.isSuccess = isSuccess;
this.status = status;
this.msg = msg;
this.object = object;
}
}
CommonResponse
에 object
멤버를 추가하고
return new CommonResponse(true, HttpStatus.OK,"Login Success", loginToken);
로그인이 성공했을 때 loginToken
을 그대로 담아 Response한다.
결과적으로 아래와 같이 Token이 정상적으로 반환되는 것을 볼 수 있다.