Spring/괴발개발

[괴발개발] REST API - 인증

오잎 클로버 2022. 3. 2. 08:20
728x90

저는 Spring로 인증을 할 때, 늘 WebSecurityConfigurerAdapter를 상속해서 개발했었습니다.

하지만, 이 방법을 하면, SpringSecurity가 필요하였고,

SpringSecurity에 아무런 설정이 없다면, 기본 로그인을 해야 하고,

그렇지 않으려면, WebSecurityConfigurerAdapter로 설정을 해주어야 했습니다.

그리고 그 외에도 설정을 해주어야 하는 부분들이 많아 프로젝트의 규모가 크면 클수록 귀찮아지고

하나하나 하기 힘들었습니다.

(물론 보안에 있어 많은 모듈들을 지원해주는 것에 있어서는 고마움을 느낍니다.)

 

그래서 이번 포스팅에서는 SpringSecurity 없이 다음 3가지 인증, 인가 기능을 구현해보는 것을 목표로 세웠습니다.

  • 비밀번호 암호화
  • 이메일 인증
  • Refresh Token & Access Token

우선 가상의 시나리오를 적어보았습니다.

1. 회원가입 및 로그인 시나리오

  1. 회원가입을 요청 → 비밀번호 암호화 → 회원 저장
  2. 로그인 요청 → 정보 확인 → Refresh Token 저장 및 전달, Access Token 전달
  3. Access Token Header에 담아 유저 info에 접근 요청 → Access Token 검증 → 접근 허가/거부

2. 토큰 만료 시나리오

  1. Header에 Access Token값 담아서 유저 info에 접근 요청 → Access Token이 만료됨 → 만료되었음을 알려줌
  2. Access Token 및 Refresh Token 값을 담아서 Access Token 재발급 요청 → Refresh Token 검증 → Access Token 발급 → Refresh Token 재발급
  3. Refresh Token 값으로 Access Token 재발급 요청 → Refresh Token이 만료됨 → 재 로그인

3. 비밀번호 찾기 및 변경 시나리오

  1. AccessToken으로 비밀번호 Path에 접근 → 해당 토큰의 정보인 유저 이메일로 비밀번호 변경 요청 URL 전달 → 비밀번호 변경 / 기존 비밀번호와 동일시, 에러 발생

DB에 다음 2가지 간단한 정보만 저장하도록 하였습니다.

  1. User (회원)
  2. RefreshToken (리프레시 토큰)

Refresh Token을 어디에 저장하느냐에 대한 점은 사람마다 다 다를 순 있지만,

대부분 쿠키 혹은 DB에 많이 저장을 하는 것 같아 DB에 저장하도록 하였습니다.

관계도

도메인(Domain)

위 사진과 같은 관계도를 갖는 엔티티들을 생성하였습니다.

@Getter
@AllArgsConstructor @NoArgsConstructor
@Builder
@Table(name = "member")
@Entity
public class User {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(unique = true, nullable = false, length = 320)
    private String email;

    @Column(nullable = false)
    private String password;

    @Column(nullable = false, length = 8)
    private String salt;

}

H2 db를 사용할 경우, 예약어 중 user가 이미 존재하여, 에러가 발생됩니다. 그렇기에 실제 테이블명은 member로 설정합니다.

@Embeddable
@AllArgsConstructor @NoArgsConstructor
public class RefreshTokenId implements Serializable {

    @OneToOne
    @JoinColumn(name = "user_id")
    private User user;

    public User getUser() {
        return user;
    }

}

JPA에서 제공해주는 복합 키를 사용하기 위해서는 2가지 방법이 있습니다.

@IdClass 어노테이션을 사용하는 방법과 @EmbededId를 사용하는 방법이 있습니다.

자세한 내용은 이 글을 참고해주세요.

@Getter
@AllArgsConstructor @NoArgsConstructor
@Builder
@Entity
public class RefreshToken {

    @EmbeddedId
    private RefreshTokenId refreshTokenId;

    @Column(nullable = false)
    private String token;

    public RefreshToken updateToken(String token) {
        this.token = token;
        return this;
    }

}

 

리포지토리는 

public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {

    Optional<RefreshToken> findByRefreshTokenIdUserId(Long refreshTokenId_user_id);

}
public interface UserRepository extends JpaRepository<User, Long> {

    Optional<User> findByEmail(String email);

    boolean existsByEmail(String email);

}

로 최대한 단순한 기능만 수행하도록 하였습니다.

(사실상 쿼리 때문에 속도에 문제가 생기는 경우가 많기에 쿼리를 수정할 수 있다면 최대한 수정한다.)

비밀번호 암호화

제가 정한 목표 중 하나인 Spring Security 없이 비밀번호를 암호화해야 하기에

자바 자체적으로 존재하는 시큐리티 라이브러리를 사용하여 구현하였습니다.

(PasswordEncoder는 Spring Security 내에 있는 라이브러리입니다.)

MessageDigest라는 라이브러리인데, 무려 자바 1.1 버전 때부터 있었고, 복호화 작업이 존재하지 않습니다.

복호화 작업이 존재하지 않는 대신, 속도가 빠릅니다.

그리고 MessageDigest만 사용하게 된다면 해당 비밀번호를 찾아낼 가능성이 존재하기에

이는 암호화를 하는 입장에서 조금 문제가 될 수도 있습니다.

그렇기에 솔트(salt)라는 작은 친구를 추가해줌으로써 보안을 더 강화시킬 수 있습니다.

@Service
public class SecurityService {

    public String getSalt() {
        int leftLimit = 48; // numeral '0'
        int rightLimit = 122; // letter 'z'
        int targetStringLength = 8;
        Random random = new Random();

        return random.ints(leftLimit,rightLimit + 1)
                .filter(i -> (i <= 57 || i >= 65) && (i <= 90 || i >= 97))
                .limit(targetStringLength)
                .collect(StringBuilder::new, StringBuilder::appendCodePoint, StringBuilder::append)
                .toString();
    }

    public String encrypt(String value) {
        String encryptPassword = null;
        try {
            MessageDigest messageDigest = MessageDigest.getInstance("SHA-256");
            messageDigest.update(value.getBytes(StandardCharsets.UTF_8));
            encryptPassword = bytesToHex(messageDigest.digest());
        } catch (NoSuchAlgorithmException e) {
            e.printStackTrace();
        }

        return encryptPassword;
    }

    private String bytesToHex(byte[] bytes) {
        StringBuilder builder = new StringBuilder();
        for (byte b : bytes) {
            builder.append(String.format("%02x", b));
        }
        return builder.toString();
    }

    public boolean validPassword(String password, String salt, String hashedPassword) {
        return encrypt(password + salt).equals(hashedPassword);
    }

}

솔트는 랜덤 String 8글자를 생성하도록 하였습니다.

MessageDigest로 비밀번호를 솔트와 결합하여 저장합니다.

동일한 방식으로 암호화를 진행했을 때, 기존 비밀번호와 동일하다면 비밀번호가 동일함을 의미합니다.

JWT 토큰

@Slf4j
@Component
public class TokenProvider {

    @Value("${spring.jpa.jwt.secret-key}")
    private String SECRET_KEY;
    
    @PostConstruct
    protected void init() {
        SECRET_KEY = Base64UrlCodec.BASE64URL.encode(SECRET_KEY.getBytes(StandardCharsets.UTF_8));
    }

    public TokenDto createToken(Long userId) {
        Claims claims = Jwts.claims();
        claims.put("user", userId);

        Date now = new Date();

        // 1시간
        long accessTokenValidMilliSecond = 60 * 60 * 1000L;
        String accessToken = Jwts.builder()
                .setHeaderParam(Header.TYPE, Header.JWT_TYPE)
                .setClaims(claims)
                .setIssuedAt(now)
                .setExpiration(new Date(now.getTime() + accessTokenValidMilliSecond))
                .signWith(SignatureAlgorithm.HS256, SECRET_KEY)
                .compact();

        // 14일
        long refreshTokenValidMilliSecond = 14 * 24 * 60 * 60 * 1000L;
        String refreshToken = Jwts.builder()
                .setHeaderParam(Header.TYPE, Header.JWT_TYPE)
                .setClaims(claims)
                .setIssuedAt(now)
                .setExpiration(new Date(now.getTime() + refreshTokenValidMilliSecond))
                .signWith(SignatureAlgorithm.HS256, SECRET_KEY)
                .compact();

        return TokenDto.builder()
                .grantType("Bearer ") // OAUTH 토큰 권한
                .accessToken(accessToken)
                .refreshToken(refreshToken)
                .accessTokenExpireDate(accessTokenValidMilliSecond)
                .build();
    }

    private Claims parseClaims(String token) {
        try {
            return Jwts.parser().setSigningKey(SECRET_KEY).parseClaimsJws(token).getBody();
        } catch (ExpiredJwtException e) {
            return e.getClaims();
        }
    }

    public Long getUserId(String token) {
        Claims claims = parseClaims(token);

        if (claims.get("user") == null) {
            throw new HttpClientErrorException(HttpStatus.BAD_REQUEST);
        }
        return claims.get("user", Long.class);
    }

    public String resolveToken(HttpServletRequest request) {
        // HTTP Request 의 Header 에서 Token Parsing -> "X-AUTH-TOKEN: jwt"
        return request.getHeader("X-AUTH-TOKEN");
    }

    public boolean validationToken(String token) {
        try {
            Jwts.parser().setSigningKey(SECRET_KEY).parseClaimsJws(token);
            return true;
        } catch (SecurityException | MalformedJwtException e) {
            log.error("잘못된 Jwt 서명입니다.");
        } catch (ExpiredJwtException e) {
            log.error("만료된 토큰입니다.");
        } catch (UnsupportedJwtException e) {
            log.error("지원하지않는 토큰입니다.");
        } catch (IllegalArgumentException e) {
            log.error("잘못된 토큰입니다");
        }
        return false;
    }

}

grantType을 Bearer로 하긴 하였지만, 실제로는 적용시키지 않았습니다.

(실제로 하기에는 다소 귀찮..)

인터셉터

토큰을 검증하기 위해 인터셉터를 사용하였습니다.

추후 인터셉터에 대해 따로 포스팅할 것이긴 하나, 간략하게 설명하자면,

특정 URL로 요청 시, ~로 가거나 혹은 ~가 끝난 후 요청을 가로채는 역할을 수행한다고 생각하시면 됩니다.

HandlerInterceptor 인터페이스를 구현하거나 HandlerInterceptorAdapter를 상속해서 구현할 수 있습니다.

@RequiredArgsConstructor
@Component
public class TokenInterceptor implements HandlerInterceptor {

    private final TokenProvider tokenProvider;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        String token = tokenProvider.resolveToken(request);

        if (token == null) {
            request.setAttribute("code", 401);
            request.setAttribute("msg", "JWT required");
            request.getRequestDispatcher("/api/error").forward(request, response);
            return false;
        }

        if (tokenProvider.validationToken(token)) {
            return true;
        }
        else {
            request.setAttribute("code", 401);
            request.setAttribute("msg", "Wrong JWT Token");
            request.getRequestDispatcher("/api/error").forward(request, response);
            return false;
        }
    }
}
@RequiredArgsConstructor
@Configuration
public class JwtInterceptorConfig implements WebMvcConfigurer {

    private final TokenInterceptor tokenInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(tokenInterceptor)
                .excludePathPatterns("/api/error")
                .excludePathPatterns("/openapi/**")
                .addPathPatterns("/api/member/**")
                .excludePathPatterns("/api/sign/**");
    }
}

Config으로 어떤 URL일 때 해당 인터셉터가 작동할 것인지 정할 수 있습니다.

Email로 비밀번호 변경은?

글이 너무 길어지는 것을 고려하여 추후 이어서 포스팅하도록 하겠습니다.

테스트

성공적으로 회원가입에 성공한 경우
동일한 이메일로 회원가입을 시도할 경우
올바른 계정으로 로그인에 성공한 경우
없는 이메일로 로그인 시도한 경우
불일치 비밀번호로 로그인했을 경우
올바른 액세스 토큰으로 info 접근
만료기간이 지난 액세스 토큰으로 접속할 경우
올바른 토큰들로 액세스 토큰 재발급 요청한 경우
만료기간이 지난 리프레시 토큰으로 액세스 토큰 재발급 요청 시

 

 

이상입니다.