Spring Clude- Service Discovery + API Gateway + Spring Sercurity

SeungTaek·2021년 10월 1일
2
post-thumbnail

본 게시물은 스스로의 공부를 위한 글입니다.
틀린 내용이 있을 수 있습니다.

Service Discovery 공부하기

API Gateway 공부하기

📒 전반적인 로직

  • Service Discovery에 API Gateway와 user-service를 등록
  • 요청을 API Gateway에 보내면 user-service로 포워딩 해준다.
  • user-service는 3가지 서비스 지원
    • POST /login : 로그인 기능. 인증이 필요 없음
    • POST /users: 회원가입 기능. 인증이 필요 없음
    • GET /health_check : 서비스 작동 여부 확인. 인증(로그인)이 필요함.
  • 인증을 위해 JWT와 Spring Security 사용
  • 사용자 암호는 BCryptPasswordEncoder로 암호화 후 내장 h2 db에 저장


📒 Service Discovery (Netflix Eureka) 만들기

  1. 🎈 스프링 부트 프로젝트 생성
    • dependence : Eureka Server

  1. 🎈 메인 클래스에 @EnableEurekaServer추가
@SpringBootApplication
@EnableEurekaServer //추가!
public class DiscoveryServiceApplication {
    public static void main(String[] args) {
        SpringApplication.run(DiscoveryserviceApplication.class, args);
    }
}

  1. application.yml 작성
server:
  port: 8761

spring:
  application:
    name: discoveryService

eureka:
  client:
    register-with-eureka: false
    fetch-registry: false


📒 API Gateway 만들기 (Spring Cloud Gateway)

  1. 🎈 스프링 부트 프로젝트 생성
    • Dependencies : Spring Boot DevTools, Eureka Discovery Client, Gateway, Lombok
    • 추가 디펜던시 : 인증을 위한 jwt 관련
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    <version>0.9.1</version>
</dependency>
<dependency>
    <groupId>javax.xml.bind</groupId>
    <artifactId>jaxb-api</artifactId>
</dependency>

  1. 🎈 application.yml 작성
server:
  port: 8000
eureka:
  client:
    register-with-eureka: true
    fetch-registry: true
    service-url:
      defaultZone: http://localhost:8761/eureka

spring:
  application:
    name: apigateway-service
  cloud:
    gateway:
      routes:
        - id: user-service
          uri: lb://USER-SERVICE
          predicates:
            - Path=/user-service/login
            - Method=POST
          filters:
            - RemoveRequestHeader=Cookie
            - RewritePath=/user-service/(?<segment>.*), /$\{segment}
            
        - id: user-service
          uri: lb://USER-SERVICE
          predicates:
            - Path=/user-service/users
            - Method=POST
          filters:
            - RemoveRequestHeader=Cookie
            - RewritePath=/user-service/(?<segment>.*), /$\{segment}
            
        - id: user-service
          uri: lb://USER-SERVICE
          predicates:
            - Path=/user-service/**
            - Method=GET
          filters:
            - RemoveRequestHeader=Cookie
            - RewritePath=/user-service/(?<segment>.*), /$\{segment}
            - AuthorizationHeaderFilter


token:
  secret: user_token
  • eureka.client.service-url.defaultZone: http://localhost:8761/eureka
    • Service Discovery 위치 지정(등록)
  • user-service는 3개의 서비스를 지원
    • POST /login : 로그인 기능. 인증이 필요 없음
    • POST /users: 회원가입 기능. 인증이 필요 없음
    • GET /health_check : 서비스 작동 여부 확인. 인증(로그인)이 필요함.
  • spring.cloud.gateway.routes
    • id: 해당 라우터의 고유 식별자
    • uri: 포워딩될 주소. lb://USER-SERVICE처럼 등록하면 eureka에 USER-SERVICE란 이름의 Application으로 연결된다.
    • predicates: 조건식
      • path, Method: 설정한 주소메서드로 요청이 들어올경우에 적용
    • filters: 기본 제공 필터를 사용하거나 커스텀 필터를 적용할 수 있다.
      • RemoveRequestHeader : 설정한 이름의 RequestHeader 제거
      • RewritePath : 요청 path를 재작성 후 해당 서비스에게 전달해준다.
        • 위의 예시의 경우 /user-service/health_check로 들어온 요청은 /health_check로 서비스에게 요청한다.
      • AuthorizationHeaderFilter: 아래에서 작성할 커스텀 필터. 인증 관련 필터이다.

  1. 🎈 커스텀 필터 작성(AuthorizationHeaderFilter)
@Component
@Slf4j
public class AuthorizationHeaderFilter extends AbstractGatewayFilterFactory<AuthorizationHeaderFilter.Config> {
    Environment env;

    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)) {
                return onError(exchange, "no authorization header", HttpStatus.UNAUTHORIZED);
            }
            String authorizationHeader = request.getHeaders().get(HttpHeaders.AUTHORIZATION).get(0);
            String jwt = authorizationHeader.replace("Bearer", "");
            if (!isJwtValid(jwt)) return onError(exchange, "JWT token is not valid", HttpStatus.UNAUTHORIZED);

            return chain.filter(exchange);
        };

    }

    private boolean isJwtValid(String jwt) {
        boolean returnValue = true;
        String subject = null;
        try {
            subject = Jwts.parser().setSigningKey(env.getProperty("token.secret"))
                    .parseClaimsJws(jwt)
                    .getBody()
                    .getSubject();
        } catch (Exception ex) {
            log.info("jwt 변환 중 예외 발생");
            returnValue = false;
        }
        if (subject == null || subject.isEmpty()) returnValue = false;

        return returnValue;
    }

    private Mono<Void> onError(ServerWebExchange exchange, String err, HttpStatus httpStatus) {
        ServerHttpResponse response = exchange.getResponse();
        response.setStatusCode(httpStatus);
        log.error(err);
        return response.setComplete();
    }
}
  • 인증 확인 방식으로 JWT를 사용한다.
  • bearer token의 경우 bearer ~~~형식으로 들어오기 때문에, 앞에 bearer문자를 빼버려야한다.
  • requst에서 관련 헤더가 있는지 확인
    • 헤더가 없거나, valid하지 않으면 HttpStatus.UNAUTHORIZED(401) 을 보냄


📒 user-service 만들기

  1. 🎈 스프링 부트 프로젝트 생성
    • Dependencies : Lombok, Spring Web, Eureka Discovery Client, Spring Boot DevTools, ,jpa
    • 추가 디펜던시: h2, validation, modelmapper, spring security, jsonwebtoken
<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <version>1.3.176</version>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>jakarta.validation</groupId>
    <artifactId>jakarta.validation-api</artifactId>
    <version>2.0.2</version>
</dependency>

<dependency>
    <groupId>org.modelmapper</groupId>
    <artifactId>modelmapper</artifactId>
    <version>2.3.8</version>
</dependency>

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    <version>0.9.1</version>
</dependency>

  1. 🎈 UserEntity, UserDto, RequestUser, ResponseUser 작성
    • UserEntity는 db 저장용
    • UserDto는 내부에서 사용
    • RequestUser은 요청받는 데이터 폼
    • ResposneUser은 응답하는 데이터 폼
@Data @Entity
@Table(name="users")
public class UserEntity {
    @Id
    @GeneratedValue
    private Long id;

    @Column(nullable = false, length = 50, unique = true)
    private String email;
    @Column(nullable = false, length = 50)
    private String name;
    @Column(nullable = false, unique = true)
    private String userId;
    @Column(nullable = false, unique = true)
    private String encryptedPwd;
}
@Data
public class UserDto {
    private String email;
    private String name;
    private String pwd;
    private String userId;
    private Date createdAt;

    private String encryptedPwd;
    private List<ResponseOrder> orders;
}
@Data
@JsonInclude(JsonInclude.Include.NON_NULL) //null인 데이터는 리턴하지 Json 결과에 포함되지 않는다.
public class ResponseUser {
    private String email;
    private String name;
    private String userId;

    private List<ResponseOrder> orders;
}
@Data
public class RequestUser {
    @NotNull(message = "Email cannot be null")
    @Size(min = 2, message = "Email not be less than two characters")
    @Email
    private String email;

    @NotNull(message = "Name cannot be null")
    @Size(min = 2, message = "Name not  be less than two characters")
    private String name;

    @NotNull(message = "Password cannot be null")
    @Size(min = 8, message = "Password must be equal or grater than 9 characters")
    private String pwd;
}

  1. 🎈 UserRepsotiory 작성
public interface UserRepository extends CrudRepository<UserEntity, Long> {
    UserEntity findByUserId(String userId);
    UserEntity findByEmail(String username);
}

  1. 🎈 UserController 작성
    • /login은 spring security에서 기본 제공하므로 따로 만들 필요없다.
@RestController
@RequestMapping("/")
@RequiredArgsConstructor
public class UserController {
    private final Environment env;
    private final Greeting greeting;
    private final UserService userService;

    @GetMapping("/health_check")
    public String status(){
        return String.format("It's Working in User Service on PORT %S",
                env.getProperty("local.server.port"));
    }

    @PostMapping("/users")
    public ResponseEntity<ResponseUser> createUser(@RequestBody RequestUser user){
        ModelMapper mapper=new ModelMapper();
        mapper.getConfiguration().setMatchingStrategy(MatchingStrategies.STRICT);

        UserDto userDto=mapper.map(user, UserDto.class);
        userService.createUser(userDto);
        ResponseUser responseUser=mapper.map(userDto, ResponseUser.class);

        return ResponseEntity.status(HttpStatus.CREATED).body(responseUser);
    }
}

  1. 🎈 UserService 인터페이스와 구현 클래스인 UserServiceImpl 작성
public interface UserService extends UserDetailsService {
    UserDto createUser(UserDto userDto);
    UserDto getUserDetailByEmail(String username);
}
@Service
@RequiredArgsConstructor
public class UserServiceImpl implements UserService {
    private final UserRepository userRepository;
    private final BCryptPasswordEncoder passwordEncoder;

    @Override
    public UserDto createUser(UserDto userDto) {
        userDto.setUserId(UUID.randomUUID().toString());

        ModelMapper mapper = new ModelMapper();
        mapper.getConfiguration().setMatchingStrategy(MatchingStrategies.STRICT);

        UserEntity userEntity = mapper.map(userDto, UserEntity.class); //변환 작업
        userEntity.setEncryptedPwd(passwordEncoder.encode(userDto.getPwd()));
        userRepository.save(userEntity);

        UserDto returnUserDto = mapper.map(userEntity, UserDto.class);
        return returnUserDto;
    }


    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        UserEntity userEntity = userRepository.findByEmail(username);
        if(userEntity==null) throw new UsernameNotFoundException(username);

        return new User(userEntity.getEmail(), userEntity.getEncryptedPwd(),
                true, true, true,
                true, new ArrayList<>());
    }

    @Override
    public UserDto getUserDetailByEmail(String email) {
        UserEntity userEntity = userRepository.findByEmail(email);
        if(userEntity==null) new UsernameNotFoundException(email);
        UserDto userDto=new ModelMapper().map(userEntity, UserDto.class);
        return userDto;
    }
}

  1. 🎈 메인 클래스 수정
@SpringBootApplication
@EnableDiscoveryClient
public class UserServiceApplication {

    public static void main(String[] args) {
        SpringApplication.run(UserServiceApplication.class, args);
    }

    @Bean
    public BCryptPasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }
}

  1. 🎈 spring security 설정을 위한 WebSecurity 작성
@Configuration
@EnableWebSecurity //WebSecurity 사용
@RequiredArgsConstructor
public class WebSecurity extends WebSecurityConfigurerAdapter {

    private final UserService userService;
    private final BCryptPasswordEncoder bCryptPasswordEncoder;
    private final Environment env;

    @Override //권한에 관련된 설정
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable();
        http.authorizeRequests().antMatchers("/**")
                .hasIpAddress("192.168.43.84") // 로컬 ip
                .and()
                .addFilter(getAuthenticationFilter());
        http.headers().frameOptions().disable(); //h2 오류 방지
    }

    private AuthenticationFilter getAuthenticationFilter() throws Exception {
        AuthenticationFilter authenticationFilter =
                new AuthenticationFilter(authenticationManager(),userService, env);
        return authenticationFilter;
    }

    @Override //인증에 관련된 설정
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userService) //userDetailService 가 구현된 클래스 등록
                .passwordEncoder(bCryptPasswordEncoder); //사용자가 입력한 패스워드는 자동으로 암호화됨
    }
}
  • spring security 설정을 위해선 WebSecurityConfigurerAdapter을 extends해야함
  • configure(HttpSecurity http)에서 요청 주소, ip에 따른 설정을 할 수 있다. 위의 경우 필터 적용
  • getAuthenticationFilter()필터의 경우 AuthenticationFilter 객체(아래에서 작성) 생성 후 리턴
  • configure(AuthenticationManagerBuilder auth): 인증과 관련된 설정

  1. 🎈 실질적 필터인 AuthenticationFilter 작성
@Slf4j
public class AuthenticationFilter extends UsernamePasswordAuthenticationFilter {
    private UserService userService;
    private Environment env;

    public AuthenticationFilter(AuthenticationManager authenticationManager,
                                UserService userService, Environment env) {
        super.setAuthenticationManager(authenticationManager);
        this.userService = userService;
        this.env = env;
    }

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request,
                                                HttpServletResponse response) throws AuthenticationException {
        try {
            RequestLogin creds = new ObjectMapper().readValue(request.getInputStream(), RequestLogin.class);
            UsernamePasswordAuthenticationToken token =
                    new UsernamePasswordAuthenticationToken(creds.getEmail(), creds.getPassword(), new ArrayList<>());

            return getAuthenticationManager().authenticate(token);
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

    @Override
    protected void successfulAuthentication(HttpServletRequest request,
                                            HttpServletResponse response,
                                            FilterChain chain,
                                            Authentication authResult) throws IOException, ServletException {
        String userName = ((User) authResult.getPrincipal()).getUsername();
        UserDto userDetails = userService.getUserDetailByEmail(userName);

        String token = Jwts.builder()
                .setSubject(userDetails.getUserId())
                .setExpiration(new Date(System.currentTimeMillis()+Long.parseLong(env.getProperty("token.expiration_time"))))
                .signWith(SignatureAlgorithm.HS512, env.getProperty("token.secret"))
                .compact();
        response.addHeader("token", token);
        response.addHeader("userId", userDetails.getUserId());

    }
}
  • Spring Security를 이용한 로그인 요청 발생 시 작업을 처리해 주는 Custom Filter 클래스
  • 처음 인증 요청이 attemptAuthentication으로 들어온다.
  • UsernamePasswordAuthenticationFilter 상속 후 attemptAuthentication()successfulAuthentication() 구현 (인증과 관련된 작업)
    • attemptAuthentication(): 전달받은 데이터를 바탕으로 Token 생성 (인증)
      • 이후 UserDetailServiceloadUserByUsername에서 repository에서 userEntity 를 찾은 후 인증에 성공하면 successfulAuthentication이 실행된다.
    • successfulAuthentication(): 인증 성공후 로직
      • JWT 토큰 생성 후 사용자에게 response에 담아서 리턴해준다.

  1. 🎈 application.yml 작성
server:
  port: 0

spring:
  application:
    name: user-service
  h2:
    console:
      enabled: true
      settings:
        web-allow-others: true
      path: /h2-console
  datasource:
    dirver-class-name: org.h2.Driver
    url: jdbc:h2:mem:testdb


eureka:
  instance:
    instance-id: ${spring.application.name}:${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:
  expiration_time: 8640000 # 하루
  secret: user_token


📒 Postman으로 테스트 해보기

  • Service Discovery, API Gateway, user-service 모두 실행
  1. http://localhost:8761에서 Service Discovery에 API Gateway와 User-service 등록 확인

  1. 회원가입

  1. 로그인
    • 토큰이 발급된걸 확인할 수 있다.

  1. 토큰 없이 /health_check 요청
    • 401 Unauthorized 발생

  1. 토큰 넣은 후 요청
    • 로그인시 받았던 토큰을 bearer token으로 넣은 후 요청시 200 정상 응답이 온걸 확인할 수 있다.

인프런의 'Spring Cloud로 개발하는 마이크로서비스 애플리케이션(MSA)(Dowon Lee)'을 스스로 정리한 글입니다.
자세한 내용은 해당 강의를 참고해주세요.

profile
I Think So!

0개의 댓글