Spring Security, Auth

SangYeon Min·2024년 3월 22일
0

PROJECT-HEARUS

목록 보기
4/12
post-thumbnail

Spring Security

최종적으로 JWT Token 발급, Login Logic을 구현하기 위한 참조 관계는 위와 같다.

Password Encrtyption

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는 단방향 변환과 매치 여부만 확인이 가능하기 때문에 추후 로그인 로직 구현시 해당 사항에 주의하여 구현해야 한다는 것이다.

Json Web Token

# 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, UserEntitiyuserRole를 추가하여 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를 생성한다.


Login Logic

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를 구현한다.

Trouble Shooting

org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'jwtTokenProvider' defined in file

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의 길이를 더 길게 설정하면 문제가 해결된다.

Forbidden

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();
    }
}

SecurityConfigfilterChainsecurityMatcher를 변경하여 해결한다.

Login 불가 오류

@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()만을 통해서 검증할 수 있다.

따라서 UserRepositoryfindFirstByEmail를 통해 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 여부를 판별하는 코드를 작성한다.

"this.jwtTokenProvider" is 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를 선언하여 문제를 해결할 수 있다.

Login Test

@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;
    }
}

CommonResponseobject 멤버를 추가하고

return new CommonResponse(true, HttpStatus.OK,"Login Success", loginToken);

로그인이 성공했을 때 loginToken을 그대로 담아 Response한다.
결과적으로 아래와 같이 Token이 정상적으로 반환되는 것을 볼 수 있다.

0개의 댓글