프로젝트/스마일게이트 윈터데브캠프

[윈터데브캠프] 팀프로젝트 - 인증 (게이트웨이, 인증 서버)

쩨이호 2023. 4. 3. 16:55

1인 프로젝트에서 MSA 기반 인증 서버 만든 것을 확장하여 구현하였다.

 

게이트웨이

 

Gateway를 통해 Jwt 토큰을 검증하고

각 서버에서 필요 시 jwt토큰으로 부터 유저 정보를 확인한다.

 

게이트웨이를 활용하였기 때문에 각 서버에서 따로 검증하는 코드를 작성할 필요가 없게 되었다.

 

인증 아키텍처

 

게이트웨이 주요 구현 내용

  • Jwt 검증
  • GlobalExceptionHandler
  • ErrorCode

 

Jwt 검증

게이트웨이에서 Jwt를 검증한 방법은 아래 코드를 통해 확인할 수 있다.

header에 Jwt 토큰이 존재하는 지, 그리고 그 토큰이 유효한 지를 검증한다.

이 때 예외 발생 시 예외를 던진다.

 

public class AuthorizationHeaderFilter extends AbstractGatewayFilterFactory<AuthorizationHeaderFilter.Config> {
    Environment env;

    @Autowired
    public AuthorizationHeaderFilter(Environment env) {
        super(Config.class);
        this.env = env;
    }
    public static class Config {

    }
    @Override
    public GatewayFilter apply(Config config) {
        return (exchange, chain) -> {
            ServerHttpRequest request = exchange.getRequest();

            if (!request.getHeaders().containsKey(HttpHeaders.AUTHORIZATION)){
                throw new JwtTokenMissingException("헤더 없음");
            }

            String authorizationHeader = request.getHeaders().get(HttpHeaders.AUTHORIZATION).get(0);
            String jwt = authorizationHeader.replace("Bearer ", "");

            log.error("jwt:"+jwt);

            validateJwtToken(jwt);
            return chain.filter(exchange);
        };
    }

    public void validateJwtToken(String token) {
        try {
            Claims claims = Jwts.parser()
                    .setSigningKey(env.getProperty("token.secret_key"))
                    .parseClaimsJws(token)
                    .getBody();
            log.error(claims.toString());
        } catch (SignatureException | MalformedJwtException |
                 UnsupportedJwtException | IllegalArgumentException | ExpiredJwtException jwtException) {
            jwtException.printStackTrace();
            throw jwtException;
        }
    }

}

 

GlobalExceptionHandler

예외 발생 시 GlobalExceptionHandler에 의해 처리된다. 발생한 예외, 에러를 확인하고 생성한 ErrorResponse 객체의 형식에 맞추어 요청에 대한 에러 응답을 보낸다.

@Component
@Order(-1) // 내부 bean 보다 우선 순위를 높여 해당 빈이 동작하게 설정
@Slf4j
public class GlobalExceptionHandler implements ErrorWebExceptionHandler {

    @Override
    public Mono<Void> handle(ServerWebExchange exchange, Throwable ex) {
        log.error("In Exception Handler");

        ErrorCode errorCode;
        ErrorResponseDto errorResponseDto;
         if (ex.getClass() == MalformedJwtException.class || ex.getClass() == SignatureException.class
        || ex.getClass() == UnsupportedJwtException.class ) {
             errorCode = ErrorCode.INVALID_TOKEN;
             errorResponseDto = errorCode.toErrorResponseDto("유효하지 않은 토큰");
        } else if (ex.getClass() == ExpiredJwtException.class){
             errorCode = ErrorCode.EXPIRED_TOKEN;
             errorResponseDto = errorCode.toErrorResponseDto("만료된 토큰");
        } else if (ex.getClass() == JwtTokenMissingException.class) {
             errorCode = ErrorCode.MISSING_TOKEN;
             errorResponseDto = errorCode.toErrorResponseDto("토큰이 전달되지 않음");
         } else {
             errorResponseDto = null;
             ex.printStackTrace();
         }
         ObjectMapper mapper = new ObjectMapper();
        String result = null;
        try {
            result = mapper.writeValueAsString(errorResponseDto);
        } catch (JsonProcessingException e) {
            e.printStackTrace();
            throw new RuntimeException(e);
        }
        byte[] bytes = result.getBytes(StandardCharsets.UTF_8);
        exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
        exchange.getResponse().getHeaders().add("Content-Type","application/json");
        DataBuffer buffer = exchange.getResponse().bufferFactory().wrap(bytes);
        return exchange.getResponse().writeWith(Flux.just(buffer));

    }
}

 

ErrorCode

에러코드를 관리하는 방식은 아래 코드와 같다.

Enum 형식을 통해 에러들을 관리하며 에러 응답 메시지를 구성한다.

public enum ErrorCode {
    INVALID_TOKEN(401, "AUTH-001", "토큰이 유효하지 않은 경우"),
    EXPIRED_TOKEN(401, "AUTH-002", "토큰이 만료된 경우"),
    MISSING_TOKEN(401, "AUTH-003", "토큰을 전달하지 않은 경우");

    private final int status;
    private final String code;
    private final String description;

    ErrorCode(int status, String code, String description) {
        this.status = status;
        this.code = code;
        this.description = description;
    }
    public ErrorResponseDto toErrorResponseDto(String msg) {
        return ErrorResponseDto
                .builder()
                .errorCode(this.code)
                .status(this.status)
                .description(this.description)
                .errorMsg(msg)
                .build();
    }
}

인증 서버

인증 서버의 주요 구현 내용

  • JPA Auditing
  • Jwt 활용
  • GlobalExceptionHandler
  • ErrorCode
  • 이메일 - SMTP 프로토콜

JPA Auditing

JPA Auditing이란 생성일, 수정일 등 DB에서 자동으로 넣어주는 값이다.

이를 통해 생성 , 변경 등 DB에 있어 기록을 확인할 수 있다.

 

이 프로젝트에서는 auditing을 위한 entity는 공통으로 묶어 SuperClass로 두고, 주로 사용하는 UserEntity에서 확장하여 사용한다.

@Data
@MappedSuperclass //Entity에서 Table에 대한 공통 매핑 정보가 필요할 때 부모 클래스에 정의하고 상속받아 해당 필드를 사용하여 중복을 제거
@EntityListeners(value = AuditingEntityListener.class)
public class BaseEntity { // auditing을 위한 entity

    @CreatedDate
    @Column(name = "created_at",updatable = false)
    private LocalDateTime createdAt;

    @LastModifiedDate
    @Column(name = "updated_at")
    private LocalDateTime updatedAt;

}
@ToString
@Builder
@Data
@AllArgsConstructor
@NoArgsConstructor
@EntityListeners(AuditingEntityListener.class)
@DynamicUpdate
@Entity
@TypeDef(name = "json", typeClass = JsonType.class)
@Table(name = "user")

public class UserEntity extends BaseEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "userid", nullable = false, unique = true)
    private String userId;
    @Column(nullable = false,length = 50, unique = true)
    private String email;
    @Column(name = "password")
    private String encryptedPwd;
    @Type(type = "json")
    @Column(name = "profile", columnDefinition = "longtext")
    private Map<String, Object> profile = new HashMap<>();
    @Column(name = "state")
    private Integer state;
    @Column(name = "user_role")
    private String role;

    @Type(type = "json")
    @Column(name = "device", columnDefinition = "longtext")
    private Map<String, Object> device = new HashMap<>();


    @Column(name = "access_at")
    private LocalDateTime accessAt;

    @Column(name = "login_at")
    private LocalDateTime loginAt;

    @Column(name = "fcm_token")
    private String fcmToken;

    public UserDto toUserDto() {
        UserDto userDto = UserDto.builder()
                .email(this.getEmail())
                .userId(this.getUserId())
                .encryptedPwd(this.getEncryptedPwd())
                .state(this.getState())
                .img(this.getProfile().get("img").toString())
                .nickname(this.getProfile().get("nickname").toString())
                .role(this.getRole())
                .build();
        return userDto;
    }
}

 

 

Jwt 활용

인증 서버는 Jwt 토큰 관련 동작을 수행한다.

주로 생성, 재발급, jwt 토큰 내 유저 정보 확인 등의 동작을 수행한다.

sercretKey의 경우 환경 변수로 설정하여 application.yml파일에서 불러오기 때문에 보안을 지키고자 하였다.

 

@Component
public class JwtTokenProvider {

    private RedisService redisService;

    public JwtTokenProvider(RedisService redisService) {
        this.redisService = redisService;
    }
    @Value("${token.access_expired_time}")
    private long ACCESS_EXPIRED_TIME;

    @Value("${token.refresh_expired_time}")
    private long REFRESH_EXPIRED_TIME;

    @Value("${token.secret_key}")
    private String SECRET_KEY;

    public String createAccessToken(UserDto userDto) {
        Map<String, Object> payloads = new HashMap<>();
        payloads.put("nickname",userDto.getNickname());
        payloads.put("userId", userDto.getUserId());
        payloads.put("email",userDto.getEmail());

        Date accessExpire = new Date(System.currentTimeMillis() + ACCESS_EXPIRED_TIME);

        String accessToken = Jwts.builder()
                .setSubject("access-token")
                .setClaims(payloads)
                .setExpiration(accessExpire)
                .signWith(SignatureAlgorithm.HS512, SECRET_KEY)
                .compact();
        return accessToken;
    }

    public String createRefreshToken(UserDto userDto) {

        Date refreshExpire = new Date(System.currentTimeMillis() + REFRESH_EXPIRED_TIME);

        String refreshToken = Jwts.builder()
                .setSubject("refresh-token")
                .setExpiration(refreshExpire)
                .signWith(SignatureAlgorithm.HS512, SECRET_KEY)
                .compact();

        redisService.setValues(userDto.getEmail(),refreshToken);

        return refreshToken;
    }

    public JwtUser getUserInfo(String token) {
        Map<String, Object> payloads = Jwts.parser().setSigningKey(SECRET_KEY).parseClaimsJws(token).getBody();
        try {
            JwtUser user = JwtUser.builder()
                    .email(payloads.get("email").toString())
                    .userId(payloads.get("userId").toString())
                    .nickname(payloads.get("nickname").toString())
                    .build();
            return user;
        } catch (NullPointerException e) {
            throw new NotAccessTokenException(token + " is not access token");
        }
    }

    public Boolean isAccessToken(String token) {
        try {
            Jwts.parser().setSigningKey(SECRET_KEY).parseClaimsJws(token);

        } catch (SignatureException | MalformedJwtException |
                 UnsupportedJwtException | IllegalArgumentException | ExpiredJwtException jwtException) {
            throw jwtException;
        }
        return true;
    }
    public String removeBearer(String bearerToken) {
        return  bearerToken.replace("Bearer ", "");
    }

    public Long getAccessExpiredTime() {
        return ACCESS_EXPIRED_TIME;
    }
    public Long getRefreshExpiredTime() {
        return REFRESH_EXPIRED_TIME;
    }

    public String getKey() {
        return SECRET_KEY;
    }
}

 

GlobalExceptionHandler

게이트웨이와 다르게 GlobalExceptionHandler를 구현하였다. 게이트웨이의 경우 WebFilter를 통해 요청을 받기 전에 필터를 거칠 때 WebFlux를 통해 검증한다.

반면, 인증 서버에서는 요청이 로직에 의해 수행될 때 발생하는 예외를 처리하기 때문에 구현하는 방식이 달라지게 되었다.

@ExceptionHandler를 통해 예외 발생 시 함수에 매핑되게 하였으며, ErrorCode와 ErrorResponse에 의해 일관성 있게 에러 응답을 보낸다.

@RestController
@ControllerAdvice
public class GlobalExceptionHandler {
    @ExceptionHandler(UserNotFoundException.class)
    public ResponseEntity<ErrorResponseDto> handleUserNotFoundException(Exception e) {
        ErrorResponseDto errorResponseDto = ErrorCode.USER_NOT_FOUND.toErrorResponseDto(e.getMessage());
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(errorResponseDto);
    }
    @ExceptionHandler(DuplicateUserException.class)
    public ResponseEntity<ErrorResponseDto> handleDuplicateUserException(Exception e) {
        ErrorResponseDto errorResponseDto = ErrorCode.DUPLICATION_USER.toErrorResponseDto(e.getMessage());
        return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponseDto);
    }
    @ExceptionHandler(WithdrawalUserException.class)
    public ResponseEntity<ErrorResponseDto> handleWithdrawalUserException(Exception e) {
        ErrorResponseDto errorResponseDto = ErrorCode.WITHDRAWAL_USER.toErrorResponseDto(e.getMessage());
        return ResponseEntity.status(HttpStatus.FORBIDDEN).body(errorResponseDto);
    }
    @ExceptionHandler(IncorrectPasswordException.class)
    public ResponseEntity<ErrorResponseDto> handleIncorrectPasswordException(Exception e) {
        ErrorResponseDto errorResponseDto = ErrorCode.INCORRECT_PASSWORD.toErrorResponseDto(e.getMessage());
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(errorResponseDto);
    }
    @ExceptionHandler(NotAccessTokenException.class)
    public ResponseEntity<ErrorResponseDto> handleAccessTokenException(Exception e) {
        ErrorResponseDto errorResponseDto = ErrorCode.NOT_ACCESS_TOKEN.toErrorResponseDto(e.getMessage());
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponseDto);
    }
    @ExceptionHandler(RedisNullException.class)
    public ResponseEntity<ErrorResponseDto> handleRedisNullException(Exception e) {
        ErrorResponseDto errorResponseDto = ErrorCode.NOT_EXISTED_REFRESH_TOKEN.toErrorResponseDto(e.getMessage());
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(errorResponseDto);
    }
    @ExceptionHandler(PasswordNotChangedException.class)
    public ResponseEntity<ErrorResponseDto> handlePasswordNotChangedException(Exception e) {
        ErrorResponseDto errorResponseDto = ErrorCode.PASSWORD_NOT_CHANGED.toErrorResponseDto(e.getMessage());
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponseDto);
    }@ExceptionHandler(IncorrectVerificationCodeException.class)
    public ResponseEntity<ErrorResponseDto> handleVerificationCodeException(Exception e) {
        ErrorResponseDto errorResponseDto = ErrorCode.INCORRECT_VERIFICATION_CODE.toErrorResponseDto(e.getMessage());
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(errorResponseDto);
    }
    @ExceptionHandler({SignatureException.class, MalformedJwtException.class,
            UnsupportedJwtException.class,IllegalArgumentException.class, ExpiredJwtException.class
    })
    public ResponseEntity<ErrorResponseDto> handleJwtException(Exception e) {
        ErrorResponseDto errorResponseDto = ErrorCode.INCORRECT_TOKEN.toErrorResponseDto(e.getMessage());
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(errorResponseDto);
    }
}

ErrorCode

ErrorCode는 게이트웨이와 크게 다른 부분이 존재하지 않는다.

public enum ErrorCode {
    INVALID_TOKEN(401, "AUTH-001", "토큰이 유효하지 않은 경우"),
    EXPIRED_TOKEN(401, "AUTH-002", "토큰이 만료된 경우"),
    MISSING_TOKEN(401, "AUTH-003", "토큰을 전달하지 않은 경우"),
    INCORRECT_TOKEN(401, "AUTH-004", "허용된 토큰이 아닌 경우"),
    NOT_ACCESS_TOKEN(400, "AUTH-005", "액세스 토큰이 아닌 경우"),
    NOT_EXISTED_REFRESH_TOKEN(404, "AUTH-006", "저장된 리프레쉬 토큰이 없는 경우"),
    UNAUTHORIZED(401, "AUTH-007", "인증에 실패한 경우"),
    WITHDRAWAL_USER(403, "AUTH-008", "탈퇴한 회원이 요청한 경우"),
    PASSWORD_NOT_CHANGED(400, "AUTH-009", "새 비밀번호로 바꿀 수 없는 경우"),
    INCORRECT_PASSWORD(401, "AUTH-010", "비밀번호가 일치하지 않는 경우"),
    INCORRECT_VERIFICATION_CODE(401, "AUTH-011", "이메일 인증 코드가 틀린 경우"),
    USER_NOT_FOUND(404, "USER-001", "해당 유저가 존재하지 않는 경우"),
    DUPLICATION_USER(409,"USER-002","해당 유저가 이미 존재하는 경우"); //409 confilct

    private final int status;
    private final String code;
    private final String description;

    ErrorCode(int status, String code, String description) {
        this.status = status;
        this.code = code;
        this.description = description;
    }
    public ErrorResponseDto toErrorResponseDto(String msg) {
        return ErrorResponseDto
                .builder()
                .status(this.status)
                .errorCode(this.code)
                .description(this.description)
                .errorMsg(msg)
                .build();
    }
}

이메일 - SMTP 프로토콜

이메일 프로토콜을 위해 Gmail 서비스를 활용했다.

username과 password가 공개되는 것을 막기 위해 jwt secretKey와 동일하게 환경변수로 설정하고 이를 불러오는 형태를 활용했다.

server:
  port: 0

spring:
  application:
    name: auth-service
  jpa:
    show-sql: true
#    hibernate:
#      ddl-auto: create-drop
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://plop-rds.cyjccp4psnuz.ap-northeast-2.rds.amazonaws.com:3306/plop?useSSL=false&allowPublicKeyRetrieval=true&useUnicode=true&serverTimezone=Asia/Seoul
    username: ${PLOP_DB_USER} # plop-db-user
    password: ${PLOP_DB_PWD} #plop-db-pwd
  redis:
    host: 127.0.0.1
    port: 6379

  mail:
    host: smtp.gmail.com
    port: 587
    username: ${MAIL_USER}
    password: ${MAIL_PWD}
    properties:
      mail:
        debug: true
        smtp:
          auth: true
          starttls:
            enable: true

eureka:
  instance:
    instance-id: ${spring.cloud.client.hostname}:${spring.application.instance_id:${random.value}}

  client:
    register-with-eureka: true
    fetch-registry: true
    service-url:
      defaultZone: http://127.0.0.1:8761/eureka

token:
  # 1시간 개발 편의성을 위해 1분 -> 1시간
  access_expired_time: 3600000
  secret_key: plip
  # 1시간
  refresh_expired_time: 3600000

logging:
  level:
    smilegate.plop.auth: DEBUG

메일을 보내기 위해 JavaMailSender 라이브러리를 활용했으며, 인증 코드는 랜덤하게 6개의 숫자로 생성하였다.

해당 숫자는 Redis에 TTL(Time-To-Live)기능을 활용하여 3분동안 메모리에 존재하도록 하였고, 메일로 전송된 인증 코드와 redis의 인증 코드가 동일할 경우 정상적으로 인증 동작이 수행된다.

@Service
@Slf4j
public class MailService {

    private JavaMailSender mailSender;

    private UserRepository userRepository;
    private RedisService redisService;

    private final long TIME_LIMIT = 3;

    @Autowired
    public MailService(UserRepository userRepository, RedisService redisService, JavaMailSender mailSender) {
        this.userRepository = userRepository;
        this.redisService = redisService;
        this.mailSender = mailSender;
    }

    public String createCode() {
        Random random = new Random();
        StringBuffer buffer = new StringBuffer();
        String authNum = "";
        for (int i=0; i<6; i++) {
            String num = Integer.toString(random.nextInt(10));

            buffer.insert(buffer.length(), num);
        }
        authNum = buffer.toString();
        log.error("key: " + authNum);
        return authNum;
    }

    public boolean checkInfo(RequestEmailVerification info){
        UserEntity user = userRepository.findByUserId(info.getUserId());
        if (user.getEmail().equals(info.getEmail()) )
            return true;
        return false;
    }
    public boolean checkInfo(RequestVerificationCode info){
        UserEntity user = userRepository.findByUserId(info.getUserId());
        if (user.getEmail().equals(info.getEmail()) )
            return true;
        return false;
    }
    @Async
    public void send(RequestEmailVerification info, String subject) {
        if (!checkInfo(info))
            throw new EntityNotFoundException("정보가 일치하지 않습니다.");
        String authNum = createCode();
        MimeMessage message = mailHelper(info,subject, authNum);
        mailSender.send(message);
        log.error("verify-"+info.getEmail());
        redisService.setValuesWithTTL("verify-"+info.getEmail(), authNum,TIME_LIMIT);
    }
    public boolean verifyCode(RequestVerificationCode verificationCode) {
        log.error(verificationCode.toString());
        if(!checkInfo(verificationCode))
            throw new UserNotFoundException("유저 ID와 유저 Email이 일치하지 않습니다.");
        UserEntity userEntity = userRepository.findByEmail(verificationCode.getEmail());
        if (userEntity == null)
            throw new UserNotFoundException(String.format("[%s] is Not Found", userEntity.getUserId()));
        if (userEntity.getState() == 9)
            throw new WithdrawalUserException("user state is 9");
        //state :9 => 회원 탈퇴, 실제 삭제은 하지 않음, 탈퇴 후 ~ 기간 이후에 삭제하는 방식?
        userEntity.setState(1);
        userRepository.save(userEntity);

        String savedVerificationCode = redisService.getValues("verify-"+verificationCode.getEmail());
        if (savedVerificationCode == null) {
            throw new RedisNullException(verificationCode.getEmail()+" there is no verification code request or verification code is expired");
        }
        else if (savedVerificationCode.equals(verificationCode.getVerificationCode()))
            return true;
        else
            throw new IncorrectVerificationCodeException(verificationCode+" is not correct");
    }

    public MimeMessage mailHelper(RequestEmailVerification info, String subject,String authNum) {
        MimeMessage message = this.mailSender.createMimeMessage();
        try {
            message.setSubject(subject,"UTF-8");
            String htmlStr = "<h1 >" + subject  + "</h1><br>"
                    +"<h2 style=\"color:blue\"> 인증 코드: " + authNum + "</h2>";
            message.setText(htmlStr, "UTF-8", "html");
            message.addRecipients(Message.RecipientType.TO, info.getEmail());

        } catch (MessagingException e) {
            e.printStackTrace();
//            throw new RuntimeException(e);
        }
        return message;
    }


}

결과적으로 아래 형태의 메일이 도착하게 된다.

이메일 인증