IT/Spring Cloud

User Microservice - 회원 로그인

김 정 환 2021. 12. 17. 22:05
반응형

앞으로 구현할 유저 서비스의 개요입니다.

 

 

APIs 입니다.

기능 URI (API GW 사용 시) URI (API Gateway  미사용 시) HTTP Method
사용자 로그인 /user-service/login /login POST

 

 

프로젝트는 이전에 사용하던 User-Service를 가져옵니다.

 

 

회원 로그인 구성은 아래와 같습니다.

  1. 사용자로부터 email과 password를 받습니다.
  2. attempAuthentication() 메소드로 인증을 시작합니다.
    1. UsernamePasswordAuthenticationToken으로 email과 password를 토큰으로 만들어 줍니다.
    2. loadUserByUsername() 메소드에서 usename( email과 동일 )로 데이터베이스에서 사용자 정보를 가져와 User객체로 만듭니다.
    3. DB에서 가져온 데이터와 사용자가 입력하여 토큰화된 데이터를 비교합니다.
  3. 인증이 완료되면 successfulAuthentication() 메소드에서 user_id를 토큰화 하고반환합니다.
    1. user_id를 가져오기 위해서 username( email과 동일 )으로 DB를 조회하여 유저 정보를 가져옵니다.
    2. 가져온 유저 정보에서 user_id를 토큰화하고 반환합니다.

 

 

인증 (Authentication) 방식을 이용하여 서비스에 접근합니다.

 

과거 인증 방식 : Session, Cookies

- 세션과 쿠키는 모바일 애플리케이션에서 유효하게 사용할 수 없음(공유 불가)

- JSON (or XML) 같은 포맷 필요

 

Token 기반 인증 시스템 

- 서버에서 발급한 토큰을 사용자에게 제공

- 사용자는 토큰을 넣어서 서버에게 요청

- 토큰을 보고 인증

 

 

소스코드 (user-service)

 

Request를 받을 클래스 RequestLogin.java를 만들어 줍니다.

@Data
public class RequestLogin {

    @NotNull(message = "Email can not be null")
    @Size(min = 2, message = "Email not be less than two characters")
    @Email
    private String email;

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

 

인증 메소드를 담은 AuthenticationFilter.java를 만들어 줍니다.

/**
 * WebSecurity.java에서 로그인 처리할 때 사용
 */

@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 {
           // Request를 request.getInputStream()으로 받고, RequestLogin 형태로 변경
           // why? POST 방식의 Request은 getInputStream()으로 바꿔주어야 처리할 수 있다.
           RequestLogin creds = new ObjectMapper().readValue(request.getInputStream(), RequestLogin.class);

           // UsernamePasswordAuthenticationToken으로 Email과 Password를 Token으로 바꿔준다.
           // Token을 getAuthenticationManager에 넘겨서 Email과 Password를 비교해서 인증처리
           return getAuthenticationManager().authenticate(
                   new UsernamePasswordAuthenticationToken(
                           creds.getEmail(),
                           creds.getPassword(),
                           new ArrayList<>()
                   )
           );
       } catch (IOException e){
            throw new RuntimeException(e);
       }

    }

    // 인증에 성공하여 로그인할 때, 어떤 값을 반환해줄 것인가. ex) 기간있는 token
    @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);

       // user_id 토큰화
       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());
    }
}

 

AuthenticationFilter.java를 이용하여 인증과 권한을 설정하기 위해서 WebSecurity.java를 수정합니다.

Configure 2개, 생성자, getAuthenticationFilter()를 추가합니다. WebSecurity는 빈으로 등록됩니다.

@Configuration
@EnableWebSecurity
public class WebSecurity extends WebSecurityConfigurerAdapter {

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

    @Autowired
    public WebSecurity(Environment env, UserService userService, BCryptPasswordEncoder bCryptPasswordEncoder) {
        this.env = env;
        this.userService = userService;
        this.bCryptPasswordEncoder = bCryptPasswordEncoder;
    }


    // 권한 관련 메소드
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable();

        // 해당 url로 들어온 요청은 인증없이 서비스 사용 가능 설정
        // 참고 : https://jhhan009.tistory.com/31
        http.authorizeRequests().antMatchers("/**").permitAll();
        http.addFilter(getAuthenticationFilter());

        http.headers().frameOptions().disable(); // h2 db에 접근이 안됨
    }


    // 인증 관련 메소드
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {

        // DB에서 입력된 Email에 맞는 pwd를 가져와야 한다. 유저 관련 비즈니스 로직은 UserService에 있다.
        // DA에서 가져온 pwd(encrypted)와 입력된 pwd(not encrypted)를 비교한다.
        // bCryptPasswordEncoder 넣어줘서 입력된 pwd(not encrypted) 암호화하여 비교한다.
        auth.userDetailsService(userService).passwordEncoder(bCryptPasswordEncoder);

        super.configure(auth);
    }


    // AuthenticationFilter 사용
    private AuthenticationFilter getAuthenticationFilter() throws Exception{
        AuthenticationFilter authenticationFilter =
                new AuthenticationFilter(authenticationManager(), userService, env);
//        authenticationFilter.setAuthenticationManager(authenticationManager());

        return authenticationFilter;
    }
}

 

UserService.java도 수정해 줍니다. getUserDetailsByEmail()을 추가했습니다.

public interface UserService extends UserDetailsService {
    UserDto createUser(UserDto userDto);

    UserDto getUserByUserId(String userId);
    Iterable<UserEntity> getUserByAll();

    UserDto getUserDetailsByEmail(String username);
}

 

UserService를 수정했으니, UserServiceImpl.java도 수정합니다.  getUserDetailsByEmail()를 구현합니다.

@Service
public class UserServiceImpl implements UserService{

    UserRepository userRepository;
    BCryptPasswordEncoder passwordEncoder;

    // Email(여기서는 Id)를 이용해서 계정 체크하는 메소드
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {

        // username은 Email로 규정
        UserEntity userEntity = userRepository.findByEmail(username);

        // Email로 찾아도 없으면, 예외 반환
        if(userEntity == null){
            throw new UsernameNotFoundException(username);
        }

        // DB에서 Email로 조회 -> DB의 pwd와 입력된 pwd 비교 -> 인증 -> 검색된 사용자 값 반환 (여기서 할 일)
        // Security 패키지의 User 클래스에 반환할 값을 넣어 줌
        return new User(userEntity.getEmail(), userEntity.getEncryptedPwd(),
                true, true, true, true,
                new ArrayList<>()); // ArrayList에는 권한을 넣어주면 된다. 지금은 없어서 빈 ArrayList

    }

    @Autowired
    public UserServiceImpl(UserRepository userRepository, BCryptPasswordEncoder passwordEncoder) {
        this.userRepository = userRepository;
        this.passwordEncoder = passwordEncoder;
    }

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

        // UserDto -> UserEntity 변환
        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);

        return null;
    }

    @Override
    public UserDto getUserByUserId(String userId) {
        UserEntity userEntity = userRepository.findByUserId(userId);

        // 조회할 유저가 없을 경우
        if(userEntity == null){
            throw new UsernameNotFoundException("User Not Found");
        }

        UserDto userDto = new ModelMapper().map(userEntity, UserDto.class);

        List<ResponseOrder> orders = new ArrayList<>();
        userDto.setOrders(orders);

        return userDto;
    }

    @Override
    public Iterable<UserEntity> getUserByAll() {
        return userRepository.findAll();
    }

    @Override
    public UserDto getUserDetailsByEmail(String username) {
        UserEntity userEntity = userRepository.findByEmail(username);

        if(userEntity == null){
            throw new UsernameNotFoundException(username);
        }

        UserDto userDto = new ModelMapper().map(userEntity, UserDto.class);

        return userDto;
    }
}

 

application.xml에 아래 내용을 추가합니다.

logging:
  level:
    com.example.userservice: DEBUG

token:
  expiration_time: 8400000 # 1분 유효
  secret: user_token # 토큰화할 때 사용할 키

 

 

소스코드 (apigateway-service)

게이트웨이도 손봐야 합니다. user-service에서 로그인을 성공하면 토큰을 사용자에게 줍니다. 사용자는 user-service의 서비스를 이용할 때, 토큰과 요청을 함께 보냅니다. 이때, 게이트웨이에서 토큰을 보고 인증 절차를 거칩니다. 아래 내용은 인증 절차를 위한 필터 추가와 경로 설정입니다.

 

pom.xml에 dependencies를 추가합니다.

<!-- Json Web Token -->
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    <version>0.9.1</version>
</dependency>

<!-- 토큰 파싱 오류 해결: DataTypeConverter -->
<dependency>
    <groupId>javax.xml.bind</groupId>
    <artifactId>jaxb-api</artifactId>
    <version>2.3.0-b170201.1204</version>
</dependency>

 

application.yaml에 설정을 아래와 같이 새로 씁니다. 설정을 보면 POST로 Path=/user-service/** 일 때만, AuthenticationHeaderFilter를 추가했습니다. 이유는 아래와 같습니다.

 

인증이 필요 없는 부분: POST 회원가입, POST 로그인

why? 회원가입을 하고 로그인을 해야 토큰을 발급

인증이 필요한 부분 : user-service의 서비스 이용, ex) GET welcome, health_check, 회원정보 조회

why? 발급 받은 토큰으로 게이트웨이를 지나서 서비스 이용

server:
  port: 8000


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

  instance:
    hostname: localhost


spring:
  application:
    name: apigateway-service

  cloud:
    gateway:
      # 공통 필터는 아래와 같이 작성
      default-filters:
        - name: GlobalFilter
          args:
            baseMessage: Spring Cloud Gateway Global Filter
            preLogger: true
            postLogger: true

      routes:
        - id: user-service
          uri: lb://USER-SERVICE
          predicates:
            - Path=/user-service/login
            - Method=POST
          filters:
            - RemoveRequestHeader=Cookie # 매번 새롭게 reqeust를 받기 위해서 reqeust header를 제거
            - 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


        - id: catalog-service
          uri: lb://CATALOG-SERVICE
          predicates:
            - Path=/catalog-service/**

        - id: order-service
          uri: lb://ORDER-SERVICE
          predicates:
            - Path=/order-service/**

        - id: first-service
          uri: lb://MY-FIRST-SERVICE
          predicates:
            - Path=/first-service/**
          filters:
#            - 아래 2개 필터는 기본으로 제공되는 필터를 이용한 것이다.
#            - AddRequestHeader=first-request, first-request-header2
#            - AddResponseHeader=first-response, first-response-header2
            - CustomFilter

        - id: second-service
          uri: lb://MY-SECOND-SERVICE
          predicates:
            - Path=/second-service/**
          filters:
#            - AddRequestHeader=second-request, second-request-header2
#            - AddResponseHeader=seocond-response, second-response-header2
            - name: CustomFilter
            - name: LoggingFilter
              args:
                baseMessage: Hi, there
                preLogger: true
                postLogger: true


token:
  secret: user_token # 토큰화할 때 사용할 키

 

AuthorizationHeaderFilter.java를 추가합니다.

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

    private Environment env;

    public AuthorizationHeaderFilter(Environment env) {
        super(Config.class); // 부모 클래스한테 사용하는 Config 정보 알려주기
        this.env = env;
    }

    // login 성공 -> 사용자는 token 받음 -> 사용자는 토큰을 가지고 서비스 접근, 토큰은 Header 안에 있음
    @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 {
            // 토큰을 풀면, 'sub : user_id' 이런 식으로 저장되어 있다. 이 sub를 가져온다.
            // 가져오기 위해서 토큰화 했던 키값(token.secret)을 넣어준다.
            subject = Jwts.parser().setSigningKey(env.getProperty("token.secret"))
                    .parseClaimsJws(jwt).getBody()
                    .getSubject();
        } catch (Exception e){
            returnValue = false;
        }

        if(subject == null || subject.isEmpty()){
            returnValue = false;
        }

        // user_id인 subject와 DB에 저장된 user_id와 비교?


        return returnValue;
    }

    // Mono, flux : WebFlux에서 데이터를 처리하는 단위
    private Mono<Void> onError(ServerWebExchange exchange, String error_msg, HttpStatus httpStatus) {
        ServerHttpResponse response = exchange.getResponse();
        response.setStatusCode(httpStatus);

        log.error(error_msg);
        return response.setComplete();
    }

    public static class Config{}
}

 

 

 

POSTMAN으로 어떻게 되는지 확인하겠습니다.

 

먼저 사용자를 등록합니다.

 

로그인을 합니다. 앗! 그런데 컨트롤러에 /login 을 만들지 않았는데?! 만들지 않아도 Spring Security가 알아서 해줍니다. 여기서 토큰을 복사합니다.

 

토큰을 이용해서 user-service의 서비스를 이용해봅니다. /welcome이 잘 작동합니다.

 

토큰이 없다면? 인증 오류가 발생합니다.

 

 

끝.

 

Spring Secutiry 아키텍쳐가 꽤 어렵습니다. 강의를 보면서 따라했지만, 여전히 어렵습니다. 별도로 공부해야겠습니다.

 

 

Git 정보

gateway-service

eureka-service

user-service

반응형

'IT > Spring Cloud' 카테고리의 다른 글

Spring Cloud Bus  (0) 2022.01.05
Configuration Service  (0) 2021.12.30
Order Service  (0) 2021.12.15
Catalogs Microservice  (0) 2021.12.15
User Microservice - 회원 등록  (0) 2021.12.13