Spring Cloud + MSA 애플리케이션 개발 6(Users Microservice 2)

지원·2024년 2월 19일
0

MSA개발

목록 보기
6/15
post-custom-banner

인증과 권한 기능 개요

  • 해당 예제에서 로그인을 하기 위해서 email , password 를 넘긴다.
  • 응답으로는 userId , JWT 를 반환하도록 구현

구현할 클래스들

  • RequestLogin - 사용자 로그인 정보를 저장하기 위한 클래스
  • AuthenticationFilter - pringSecurity 를 이용한 로그인 요청 발생 시 작업을 처리해주는 Custom Filter 클래스 (UsernamePasswordAuthenticationFilter 상속)
  • attemptAuthentication() , successfulAuthentication() 함수 구현
  • UserDetailsService 등록 - 기존에 있던 UserService 인터페이스에 userDetailsService 를 상속
  • API Getway Service 수정 - 원래는 yml 파일에서 predicates 를 /user-service/ 로 통일했지만 이제는 /user-service/login (로그인) , /user-service/users (POST , 회원가입) , /user-service/ (GET) 으로 나눈다.
    -> 이때 filters: 부분을 다음과 같이 바꿀 예정
filters:
	RemoveRequestHeader=Cookie
    RewirtePath=/user-service/(?<segment>.*) , /$\{segment}
  • RewirtePath 부분은 원래 API 요청을 할 때 /user-service/welcome , /user-service/users 처럼 /user-service/ 이 부분은 계속 포함해서 보내줬다.
  • 하지만 /welcome , /users 로만 보내는게 더 깔끔하고 좋을 거 같다.
  • Spring Cloud 에서 /user-service/ 이 부분을 없애고 요청을 해주는게 RewirtePath 라고 생각하면 된다.
  • 즉 /user-servic/(?...) 이 부분을 -> /${segment} 만 보내도록 해라
  • /user-service/welcome -> /welcome

위에서 구현할 클래스들을 직접 구현해보자.

AuthenticationFtiler 추가

public class AuthenticationFilter extends UsernamePasswordAuthenticationFilter {

    public AuthenticationFilter(AuthenticationManager authenticationManager) {
        super(authenticationManager);
    }

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
        try {
            RequestLogin creds = new ObjectMapper().readValue(request.getInputStream() , RequestLogin.class);
            // 인증 정보로 만들기 UsernamePasswordAuthenticationToken 으로 변경해야한다.
            // 사용자가 입력한 이메일,비밀번호 값을 스프링 시큐리티에서 사용할 수 있는 값으로 변경하기 위한 것이다.
            // 바꾼후 Manager 에게 그 객체를 넘긴다.
            return getAuthenticationManager().authenticate(
                    new UsernamePasswordAuthenticationToken(creds.getEmail() , creds.getPassword(),new ArrayList<>()));
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }
}
  • RequestLoing.class 를 만들고 여기에 email , password 필드를 만든다.
  • request 에서 넘어온 값들을 가지고 RequestLogin 으로 만든다.
  • 이때 스프링 시큐리티에서 사용할 수 있는 값으로 바꾸기 위해 UsernamePasswordAuthenticationToken 으로 변환하고 이 값을 AuthenticationManager 에게 넘겨준다.
  • Manager 에서 해당 Token 을 가지고 인증을 한다.
// WebSecurity.class
    @Bean
    protected SecurityFilterChain configure(HttpSecurity http) throws Exception {

        AuthenticationManagerBuilder authenticationManagerBuilder = http.getSharedObject(AuthenticationManagerBuilder.class);

        // Manager 에다가 userDetailsService , passwordEncoder 넣어준다.
        // pwd 필드도 bCryptPasswordEncoder 되서 들어간다.
        authenticationManagerBuilder.userDetailsService(userService).passwordEncoder(bCryptPasswordEncoder);
        AuthenticationManager authenticationManager = authenticationManagerBuilder.build();


        http.csrf( (csrf) -> csrf.disable());

        http.authorizeHttpRequests((authz) -> authz
                .requestMatchers(new AntPathRequestMatcher("/**")).permitAll()
        ).authenticationManager(authenticationManager);


        http.addFilter(getAuthenticationFilter(authenticationManager));

        http.headers((headers) -> headers.frameOptions((frameOptions) -> frameOptions.sameOrigin()));
        return http.build();
    }


    // input_pwd 와 db_pwd 를 비교하기 위해 encrypted 를 하고 비교해야한다.
    private AuthenticationFilter getAuthenticationFilter(AuthenticationManager authenticationManager) {
        return new AuthenticationFilter(authenticationManager);
    }
  • 위에서 만든 Filter 를 적용시키기 위해 http.addFilter 에 넣어준다.
  • AuthenticationFilter 생성자에서 AuthenticationManager 를 받고 있으니까 같이 넣어준다.
  • configure 안에서 Manager 를 가지고 오기 위해서는 configure 안에 1,2번째 코드를 참고하면 된다.

loadUserByUsername() 구현

  • AuthenticationManager 에다가 UserDetailsService 로 구현한 Service 와 PasswordEncoder 를 넘겨야 한다.
  • 그래야 스프링 시큐리티에서 인증 절차를 수행할 수 있고 현재 DB 에 password 를 넣을 때 Encoder 를 하고 넣기 때문에 그 정보도 Manager 에 넣어줘야한다.
        AuthenticationManagerBuilder authenticationManagerBuilder = http.getSharedObject(AuthenticationManagerBuilder.class);
        AuthenticationManager authenticationManager = authenticationManagerBuilder.build();

        // Manager 에다가 userDetailsService , passwordEncoder 를 등록한다.
        authenticationManagerBuilder.userDetailsService(userService).passwordEncoder(passwordEncoder);
  • 위에서 Manager 를 가져올때 봤던 코드인데 그렇게 가져온 Manager 에다가 userService 와 passwordEncoder 객체를 전달한다.

이때 현재 우리가 만든 UserService 는 UserDetalisService 를 상속받지 않았기 때문에 컴파일 에러가 발생하기 때문에 UserService 에 UserDetalisService 를 상속하고 loadUserByUsername() 메소드를 구현하면 된다.

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        // username == loginId 와 같다고 생각하면 된다. 예제에서는 email
        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<>());
    }
  • 위와 같이 구현하면 되고 userEntity 가 null 이 아니라면 User 객체를 만들어서 반환하는 로직이다.
  • DB 에는 Encrypted 된 pwd 가 넘어가기 때문에 User 객체를 만들때도 Encrypted 된 pwd 를 넘겨준다.

Routes 정보 변경하기

# apigateway-service.application.yml

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}
  • 처음 설명했던 대로 /user-service/welcome 으로 온 요청을 /welcome 으로만 올 수 있도록 하기 위해서 yml 파일을 수정
  • 수정했기 때문에 UserController 에서는 RequestMapping 을 이제 "/user-servce" 로 받을 필요가 없다.

WebSeucirty.class 에서 configure 메소드

  • 여기서 AuthenticationManagerBuilder 를 통해서 authenticationManager 를 가져와야 한다.
  • 이때 authenticationManagerBuilder.userDetailsService().passwordEncoder() API 를 통해서 내가 만든 UserService 를 넣고 사용할 Encoder 를 지정해준다.
  • 그런후 http.authenticationManager(authenticationMnager) 처럼 만든 Manager 를 등록해줘야한다.

postman 으로 /user-service/users 로 회원가입을 하고 /user-service/login 을 해본 결과 정상적으로 동작하는 것을 확인했다.

로그인 처리 과정

JSON 으로 email , password 정보를 넣고 /login 을 실행

  • AuthenticationFilter 에서 attemptAuthentication () 호출 -> loadUserByUsername() 메서드가 호출 -> 성공시 successfulAuthentication() 메서드 호출
  • AuthenticationFilter.attemptAuthentication() -> UsernamePasswordAuthenticationToken 으로 변환 -> UserDetailService.loadUserByUsername() -> UserRepository 에서 존재하는 Entity 가 있는지 확인 -> 존재한다면 AuthenticationFilter.successfulAuthentication() 호출

JWT 는 userId 를 가지고 발급할 예정이다.

  • 하지만 스프링 시큐리티에서 가지고 있는 정보는 email 을 가지고 있다.
  • 그래서 successfulAuthentication 에서 email 을 가지고 UserDto 를 가져오고 UserDto 를 통해서 userId 를 가지고 JWT 를 만든다.
  • 이렇게 생성된 Token 은 Response 헤더에 저장
  • 즉 인증 성공시 사용자에게 Token 을 발급
  • 그래서 AuthenticationFilter 의 생성자가 조금 달라진다.

사용자 인증을 위한 검색 메소드를 추가한다. (Repository)
-> Email 로 User 객체를 찾는 메소드

JWT 에서 설정해야되는게 어떠한 내용으로 토큰을 만들것인지를 정해야한다.(setSubject())

  • 여기에 userId 로 만들고 싶기 떄문에 email 을 가지고 User 객체를 가져오려고 하는 것

token 과 userId 를 response 헤더에 넣는다.

  • 사실 token 만 넣어도 되지만 중간에 변조가 발생했는지 확인하기 위해서 userId 도 같이 넣는다.

로그인 성공 처리

    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response,
                                            FilterChain chain, 
                                            Authentication authResult) throws IOException, ServletException {
        String userName = ((User) authResult.getPrincipal()).getUsername();
        UserDto userDetails = userService.getUserDetailsByEmail(userName);
    }
  • 위와 같이 successfulAuthentication 에서 넘어온 authResult 객체를 사용하여 userName(email) 을 알아내고 그것을 사용해서 userDto 를 가져온다.
  • userDto 를 가지고 userId 를 추출해 JWT 를 만든다.

JWT 생성

        <!-- jjwt 라이브러리 -->
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-api</artifactId>
            <version>0.11.2</version> <!-- 최신 버전 확인 필요 -->
        </dependency>
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-impl</artifactId>
            <version>0.11.2</version> <!-- 최신 버전 확인 필요 -->
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-jackson</artifactId>
            <version>0.11.2</version> <!-- 최신 버전 확인 필요 -->
            <scope>runtime</scope>
        </dependency>
token:
  expiration_time: 86400000
  secret: user_tokenuser_tokenuser_tokenuser_tokenuser_tokenuser_tokenuser_tokenuser_tokenuser_tokenuser_token
  • 하루짜리 토큰(ms) : 60(초) 60(분) 24(시간) * 1000(ms) => 86400000
  • secret 은 길면 길수록 좋다 (짧을 시 예외가 발생)
    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response,
                                            FilterChain chain,
                                            Authentication authResult) throws IOException, ServletException {
        String userName = ((User) authResult.getPrincipal()).getUsername();
        UserDto userDetails = userService.getUserDetailsByEmail(userName);

        byte[] secretKeyBytes = Base64.getEncoder().encode(env.getProperty("token.secret").getBytes());
        SecretKey secretKey = Keys.hmacShaKeyFor(secretKeyBytes);


        String token = Jwts.builder()
                // userId 로 token 을 만들겠다.
                .setSubject(userDetails.getUserId())
                // 현재 시간에서 yml 파일에 있는 시간을 더한 시간까지 만료일 설정
                .setExpiration(Date.from(now().plusMillis(Long.parseLong(env.getProperty("token.expiration_time")))))
                .signWith(secretKey)
                .compact();

        response.addHeader("token" , token);
        response.addHeader("userId" , userDetails.getUserId());
    }
  • yml 에 있는 정보를 가지고 와서 JWT 을 생성
  • 생성한 Token 을 response 헤더에 넣는다.

POSTMAN 으로 해본 결과 token 이 잘 발급되고 header 에 포함되서 넘어온 것을 확인할 수 있다.

JWT 처리 과정

전통적인 인증 시스템

  • 클라이언트에서 서버로 username 과 password 를 보낸다.
  • session , cookie 를 사용

문제점
1. 세션과 쿠키는 모바일 애플리케이션에서 유효하게 사용할 수 없다.
2. 렌더링된 HTML 페이지가 반환되지만, 모바일 애플리케이션에서는 JSON 과 같은 포멧이 필요하다.

문제를 생각해보면 모바일 app 을 개발할 때 많이 쓰는 React 기술이 있다.

  • 이 기술은 js 기반으로 되어 있다.
  • backend 는 java 를 활용한다고 가정해보면 로그인 기능에서 세션을 발급할 때 Java 는 같은 Java 언어로 된 계열에만 전달할 수 있다.
  • 그래서 JAVA 에서 React 로 세션을 전달할 수 없다는 것이다.
  • 물론 전달 받을 수 있는 Reduct 라는 방법도 생기기는 했지만 Java 에서 발급된 인증 방법을 다른 언어에서도 쓰기 위해서 또 다른 Token 을 중점적으로 생각하자.

Token 기반 인증 시스템

  • 클라이언트에서 서버로 username 과 password 를 보낸다. (여기까지는 동일)
  • 서버에서 인증 절차를 거친뒤 세션을 발급하는 것이 아닌 JWT 를 발급한다.
  • 클라이언트에서는(웹,앱 모두) 받은 Token 을 가지고 서버에게 계속 요청할 수 있다.
  • 세션 , 쿠키 대신 JWT(Json Web Token) 을 사용

JWT

  • 인증 헤더 내에서 사용되는 토큰 포맷이며 두 개의 시스템끼리 안전한 방법으로 통신할 수 있다.
  • jwt.io 에 들어가서 넘어온 토큰을 붙여넣으면 어떠한 값들이 들어있는지 알 수 있다.
  • 해당 예제에서는 userId 로 만들었기 떄문에 userId 값이 나온다.
  • nodejs , php , java , python 등 지원

JWT 장점

  • 클라이언트 독립적인 서비스 (클라이언트와 계속 통신할 수 있는 방법이 생긴다)
  • No Cooke-Session (No CSRF , 사이트간 요청 위조)
  • 지속적인 토큰 저장
    -> 만약 서버가 3개라고 가정해볼때 클라이언트에서 Load Balancer 를 통해 Server 에 접근할 것이다.
    -> 1번 서버에서 발급된 토큰을 3번 서버에서도 사용할 수 있다.

API Gateway Service 에 Spring Security 와 JWT 사용 추가

  • Gateway 에 추가를 하는 이유는 클라이언트가 Gateway 로 요청이 올 때 그 Token 이 정상적으로 만들어진 토큰인지 아닌지를 확인해야하기 때문이다.
  • 해당 작업은 AuthorizationHeaderFilter 에서 구현
  • apply 함수를 구현
  • token , userId 를 header 에 넣어서 구현했는데 이런 내용을 HttpHeaders.AUTHORIZATION 을 통해서 확인
  • isJwtValid() 함수를 만들면서 해당 토큰이 정상적인 토큰인지 확인해본다.
    -> 확인하는 로직은 JWT 토큰을 decoder 해보고 그 결과가 userId 와 같은지 같지 않은지 보면된다.
  • yml 파일도 수정해야 하는데 회원가입 , 로그인에서는 JWT 에 대한 확인을 하지 않아도 된다.
    -> 그 나머지에서는 JWT 에 대한 확인을 해야하기 때문에 yml 파일에서 filters: 에서 AuthorizationHeaderFilter 를 추가해줘야한다.

인증 정보에 아무것도 전달하지 않게 되면 401 오류를 발생하도록 한다.

  • 회원정보를 요청할 때 Authorization 에서 TYPE 에 Bearer Token 을 넣고 요청을 하면 정상적으로 성공할 수 있도록 기능을 추가한다.
  • API 에 접속하기 위해서 access_token 을 API 서버에 제출해서 인증처리하며 이 방법은 OAuth2.0 을 위해서 고안된 방법이다.

추가할 내용은 apigateway-service 에다가 JWT 토큰의 유효성을 검사할 수 있는 기능을 추가한다.

  • 클라이언트에 요청은 apigateway-service 를 거쳐서 서버에 가기 때문에 서버에 가기 전에 유효성을 검사한다.

AuthorizationHeaderFilter 추가

@Component
@Slf4j
public class AuthorizationHeaderFilter extends AbstractGatewayFilterFactory<AuthorizationHeaderFilter.Config> {

    private Environment env;
    public AuthorizationHeaderFilter(Environment env) {
        super(Config.class);
        this.env = env;
    }

    public static class Config {

    }

    @Override
    public GatewayFilter apply(Config config) {
        // login -> token -> 해당 token 을 가지고 요청 -> 그 token 은 header 에 포함
        return (exchange, chain) -> {
            // exchange 에서 request , response 를 얻을 수 있다.
            ServerHttpRequest request = exchange.getRequest();

            if (!request.getHeaders().containsKey(HttpHeaders.AUTHORIZATION)) {
                // 에러를 반환
                return onError(exchange , "No authorization header" , HttpStatus.UNAUTHORIZED);
            }

            // 이 안에 token 값이 있을 것
            String authorizationHeader = request.getHeaders().get(HttpHeaders.AUTHORIZATION).get(0);
            // Bearer 부분을 "" 로 없애면서 jwt 만 가져온다.
            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) {
        byte[] secretKeyBytes = Base64.getEncoder().encode(env.getProperty("token.secret").getBytes());
        SecretKeySpec signingKey = new SecretKeySpec(secretKeyBytes, SignatureAlgorithm.HS512.getJcaName());
        boolean returnValue = true;
        // jwt.io 에서 token 으로 subject 를 알 수 있다.
        String subject = null;
        try {
            JwtParser jwtParser = Jwts.parserBuilder().setSigningKey(signingKey).build();
            // Subject 를 추출했고, 올바른 값인지 확인하면 된다.
            subject = jwtParser.parseClaimsJws(jwt).getBody().getSubject();
        } catch (Exception ex) {
            returnValue = false;
        }
        if (subject==null || subject.isEmpty()) {
            returnValue = false;
        }
        return returnValue;
    }

    // Mono(단일값) , Flux(다중값) -> Spring WebFlux 에서 나오는 개념
    // API 처리할 때 비동기 방식으로 처리
    private Mono<Void> onError(ServerWebExchange exchange, String err, HttpStatus httpStatus) {
        ServerHttpResponse response = exchange.getResponse();
        response.setStatusCode(httpStatus);
        log.error(err);
        // Mono 타입으로 반환 -> setComplete()
        return response.setComplete();
    }
}
  • apply 함수를 구현할 때 request.getHeader().containsKey() 로 먼저 인증 정보가 있는지 확인을 하고 없다면 Error 를 return 한다.
  • 만약 있다면 그 인증 정보를 가지고 오는데 해당 인증 정보는 List 로 반환되기 떄문에 그중에 처음에 있는 값을 가져온다.
  • 그런후 넘어오는 값에 Bearer 값이 포함되기 때문에 이 부분을 빈 문자열로 치환하고 jwt 값 만을 가져온다.
    -> POSTMAN 으로 요청하면 주소 적는 칸 아래에 Authorization 창이 있고 여기에 Type 에 Bearer 이 있는데, Bearer 로 하고 Token 을 전달하기 때문에 헤더에 Bearer 가 포함된다.
  • 해당 jwt 를 가지고 유효한 토큰인지 확인하는 isJwtValid() 로 간다.
  • isJwtValid() 에는 우리가 위에서 JWT 를 만들 때 hmacShaKeyFor() 로 만들었기 떄문에 HS512 알고리즘을 사용해서 signingKey 를 가지고 온다.
  • 그런후 JwtParser 를 가지고 jwt 를 decoder 한 후 subject 값을 가지고 오고 이 값의 유효성을 검사해서 true , false 를 반환한다.
  • onError 는 Spring MVC 가 아닌 Spring WebFlux 에서 나온 것인데 API 를 처리할 때 비동기 방식으로 처리할 수 있다.

yml 파일 수정

spring:
  application:
    name: apigateway-service
  cloud:
    gateway:
      routes:
        - id: user-service
          uri: lb://USER-SERVICE
          predicates:
            - Path=/user-service/**
            - Method=GET
          filters:
            - RemoveRequestHeader=Cookie
            - RewritePath=/user-service/(?<segment>.*), /$\{segment}
            - AuthorizationHeaderFilter
  • 위와 같이 로그인 , 회원가입 부분은 제외한 곳에서는 Token 에 대한 유효성 검사가 있어야 하기 때문에 /user-service/** 부분에 AuthorizationHeaderFilter 를 적용시켜야한다.

테스트

  • 지금까지 만들었던 로그인, 토큰 발급 , 토큰 유효성 검사 등 기능이 잘 동작하는지 확인해보자.
  • token 을 넘기지 않고 /welcome , /health_check 접근해보면 401 에러가 발생한다.
  • 하지만 token 을 Barer Token 으로 보내면 정상적으로 처리가 된다.

참고자료

profile
덕업일치
post-custom-banner

0개의 댓글