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;
}
}
결과적으로 아래 형태의 메일이 도착하게 된다.
'프로젝트 > 스마일게이트 윈터데브캠프' 카테고리의 다른 글
[윈터데브캠프] 팀 프로젝트 - 유저, 푸시 서버 (0) | 2023.04.03 |
---|---|
[윈터데브캠프] 팀프로젝트 - API,DB (+요청/응답, 포트) (0) | 2023.04.03 |
[윈터데브캠프] 팀프로젝트 - 아키텍처 (0) | 2023.03.31 |
1인 프로젝트 - MSA 기반 인증서버 개발 (0) | 2023.02.19 |