반응형

본 내용은 인프런의 이도원 님의 강의 "Spring Cloud로 개발하는 마이크로서비스 애플리케이션(MSA)" 내용을 바탕으로 정리한 내용입니다.

 

RequestLogin 생성

package cohttp://m.example.UserService.vo; 

import lombok.Data;  

import javax.validation.constraints.Email;  
import javax.validation.constraints.NotNull;  
import javax.validation.constraints.Size;  
/**  
 * 사용자 로그인 정보  
 */  
@Data  
public class RequestLogin {  
    @NotNull(message = "Email cannot 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 grater than 8 characters and less than 16 characters")  
    private String password;  
}
  • 사용자 로그인 정보를 저장하기 위한 객체

UserRepository 수정

package cohttp://m.example.UserService.jpa; 

import org.springframework.data.repository.CrudRepository;  
import org.springframework.stereotype.Repository;  

@Repository  
public interface UserRepository extends CrudRepository<UserEntity, Long> {  
    UserEntity findByUserId(String userId);  
    UserEntity findByEmail(String username);  
}
  • 이메일로 사용자 정보 조회하는 메서드 추가

UserService 수정

package com.example.UserService.service;  

import cohttp://m.example.UserService.dto.UserDto; 
import cohttp://m.example.UserService.jpa.UserEntity; 
import org.springframework.security.core.userdetails.UserDetailsService;  

public interface UserService extends UserDetailsService {  
    UserDto createUser(UserDto userDto);  
    UserDto getUserByUserId(String userId);  
    Iterable<UserEntity> getUserByAll();  
}
  • Spring Security에서 사용자의 인증과 관련된 데이터를 로드하기 위한 인터페이스UserDetailsService를 상속

UserServiceImpl 수정

package com.example.UserService.service;  

import cohttp://m.example.UserService.dto.UserDto; 
import cohttp://m.example.UserService.jpa.UserEntity; 
import cohttp://m.example.UserService.jpa.UserRepository; 
import cohttp://m.example.UserService.vo.ResponseOrder; 
import org.modelmapper.ModelMapper;  
import org.modelmapper.convention.MatchingStrategies;  
import org.springframework.beans.factory.annotation.Autowired;  
import org.springframework.security.core.userdetails.User;  
import org.springframework.security.core.userdetails.UserDetails;  
import org.springframework.security.core.userdetails.UsernameNotFoundException;  
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;  
import org.springframework.stereotype.Service;  

import java.util.ArrayList;  
import java.util.List;  
import java.util.UUID;  

@Service  
public class UserServiceImpl implements UserService{  
    @Autowired  
    private UserRepository userRepository;  
    @Autowired  
    private BCryptPasswordEncoder bCryptPasswordEncoder;  

    @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 createUser(UserDto userDto) {  
        // 랜덤으로 ID 생성  
        userDto.setUserId(UUID.randomUUID().toString());  
        ModelMapper mapper = new ModelMapper();  
        // 값들이 정확히 같을 때만 엔티티 클래스로 변환하도록 설정  
        mapper.getConfiguration().setMatchingStrategy(MatchingStrategies.STRICT);  
        UserEntity userEntity = mapper.map(userDto, UserEntity.class);  
        userEntity.setEncryptedPwd(bCryptPasswordEncoder.encode(userDto.getPwd()));  

        userRepository.save(userEntity);  

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

        return returnUserDto;  
    }  

    /**  
     * 사용자 ID로 사용자 정보 조회  
     * @param userId  
     * @return  
     */  
    @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> orderList = new ArrayList<>();  
        userDto.setOrders(orderList);  

        return userDto;  
    }  

    /**  
     * 전체 사용자 조회  
     * @return  
     */  
    @Override  
    public Iterable<UserEntity> getUserByAll() {  
        return userRepository.findAll();  
    }  
}
  • 사용자 정보를 반환하는 loadUserByUsername 메서드 구현

AuthenticationFilter

package cohttp://m.example.UserService.filter; 

import cohttp://m.example.UserService.vo.RequestLogin; 
import com.fasterxml.jackson.databind.ObjectMapper;  
import jakarta.servlet.FilterChain;  
import jakarta.servlet.ServletException;  
import jakarta.servlet.http.HttpServletRequest;  
import jakarta.servlet.http.HttpServletResponse;  
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;  
import org.springframework.security.core.Authentication;  
import org.springframework.security.core.AuthenticationException;  
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;  

import java.io.IOException;  
import java.util.ArrayList;  

public class AuthenticationFilter extends UsernamePasswordAuthenticationFilter { 
    /**  
     * 사용자 인증  
     * @param request  
     * @param response  
     * @return  
     * @throws AuthenticationException  
     */    @Override  
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)  
            throws AuthenticationException {  
        try {  
            // request는 post 형태라 inputstream 으로 확인  
            RequestLogin creds = new ObjectMapper().readValue(request.getInputStream(), RequestLogin.class);  

            // 사용자 정보를 통해 인증처리  
            return getAuthenticationManager().authenticate(  
                    new UsernamePasswordAuthenticationToken(  
                            creds.getEmail(),  
                            creds.getPassword(),  
                            new ArrayList<>()  
                    )  
            );  
        }catch (IOException e){  
            throw new RuntimeException(e);  
        }  
    }  

    /**  
     * 인증성공 후 처리  
     * @param request  
     * @param response  
     * @param chain  
     * @param authentication  
     * @throws IOException  
     * @throws ServletException  
     */    @Override  
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response  
            , FilterChain chain, Authentication authentication) throws IOException, ServletException{  
    }  
}
  • 인증 필터 생성

WebSecurityConfig 수정

package cohttp://m.example.UserService.config; 

import cohttp://m.example.UserService.filter.AuthenticationFilter; 
import com.example.UserService.service.UserService;  
import lombok.RequiredArgsConstructor;  
import org.springframework.beans.factory.annotation.Autowired;  
import org.springframework.context.annotation.Bean;  
import org.springframework.context.annotation.Configuration;  
import org.springframework.core.env.Environment;  
import org.springframework.security.authentication.AnonymousAuthenticationToken;  
import org.springframework.security.authorization.AuthorizationDecision;  
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;  
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;  
import org.springframework.security.config.annotation.web.builders.HttpSecurity;  
import org.springframework.security.config.annotation.web.configurers.CsrfConfigurer;  
import org.springframework.security.core.Authentication;  
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;  
import org.springframework.security.web.SecurityFilterChain;  
import org.springframework.security.web.access.intercept.RequestAuthorizationContext;  

import java.util.function.Supplier;  

@Configuration  
@RequiredArgsConstructor  
public class WebSecurityConfig {  
    private static final String ALLOWED_IP_ADDRESS_MATCHER = "127.0.0.1";  
    private final UserService userService;  
    private final Environment env;  
    private final BCryptPasswordEncoder bCryptPasswordEncoder;  
    private final AuthenticationConfiguration authenticationConfiguration;  

    /**  
     * 요청에 대한 인증 처리  
     * 특정 ip이외에서 접속할 경우 모든 요청은 인증이 필요하다.  
     * @param http  
     * @return  
     * @throws Exception  
     */    @Bean  
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {  
        http  
                .authorizeHttpRequests(authz -> authz  
                        .requestMatchers("/**").access(this::hasIpAddress)// 여기에 특정 IP 주소를 지정  
                        .anyRequest().authenticated()  
                )  
                .addFilter(getAuthenticationFilter()) // getAuthenticationFilter() 메서드를 구현해야 합니다.  
                .csrf(CsrfConfigurer::disable); // CSRF 보호 비활성화  
        return http.build();  
    }  

    /**  
     * 요청한 IP와 접속을 허용하는 IP가 일치하는지 확인  
     * @param authentication  
     * @param object  
     * @return  
     */  
    private AuthorizationDecision hasIpAddress(Supplier<Authentication> authentication, RequestAuthorizationContext object) {  
        return new AuthorizationDecision(  
                //!(authentication.get() instanceof AnonymousAuthenticationToken) &&  
                        ALLOWED_IP_ADDRESS_MATCHER.equals(object.getRequest().getRemoteAddr())  
        );  
    }  

    /**  
     * 사용자 정의 인증 필터를 생성하고 인증관리자를 설정한다.  
     * @return  
     * @throws Exception  
     */    private AuthenticationFilter getAuthenticationFilter() throws Exception {  
        AuthenticationFilter authenticationFilter = new AuthenticationFilter();  
        authenticationFilter.setAuthenticationManager(authenticationConfiguration.getAuthenticationManager());  
        return authenticationFilter;  
    }  

    /**  
     * 사용자 서비스와 비밀번호 인코더 설정  
     * @param auth  
     * @throws Exception  
     */    @Autowired  
    public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {  
        auth.userDetailsService(userService).passwordEncoder(bCryptPasswordEncoder);  
    }  
}

GatewayService

application.yml 수정

     routes:  
#        - id: UserService # 사용자 서비스  
#          uri: lb://UserService  
#          predicates:  
#            - Path=/userService/**  
        - id: UserService # 사용자 로그인  
          uri: lb://UserService  
          predicates:  
            - Path=/userService/login  
            - Method=POST  
          filters:  
            - RemoveRequestHeader=Cookie  
            - RewritePath=/userService/?(?<segment>.*), /$\{segment} # /userService 패턴이 들어오면 제거해서 호출  
        - id: UserService  
          uri: lb://UserService  
          predicates:  
            - Path=/userService/users  
            - Method=POST  
          filters:  
            - RemoveRequestHeader=Cookie  
            - RewritePath=/userService/?(?<segment>.*), /$\{segment} # /userService 패턴이 들어오면 제거해서 호출  
        - id: UserService  
          uri: lb://UserService  
          predicates:  
            - Path=/userService/**  
            - Method=GET  
          filters:  
            - RemoveRequestHeader=Cookie  
            - RewritePath=/userService/?(?<segment>.*), /$\{segment} # /userService 패턴이 들어오면 제거해서 호출
  • userSerivce 패턴으로 서버를 호출할떄 userService를 제거하고 userService를 호출한다.

결과

![[Pasted image 20240624210800.png]]

  • login을 구현하지 않아도 스프링 시큐리티를 사용하면 자동으로 인증을 진행한다.
  • 200 OK 응답이 나오므로 로그인에 성공했다.

JWT 토큰 적용

기존 로그인 방식의 문제점

  • 세션과 쿠키는 모바일 애플리케이션에서 유효하게 사용할 수 없음(공유 불가)
  • 랜더링된 HTMI 페이지가 반환되지만 , 모바일 애플리케이션에서는 JSON과 같은 포맷이 필요하다.

JWT(Json Web Token)

  • 인증 헤더 내에서 사용되는 토큰 포맷이다.
  • 두 개의 시스템끼리 안전한 방법으로 통신이 가능하다.
  • https://jwt.io 사이트에서 토큰 값 확인이 가능하다.
  • 클라이언트의 독립적인 서비스(statelss)가 가능하다.

UserService 수정

pom.xml 추가

<dependency>  
    <groupId>io.jsonwebtoken</groupId>  
    <artifactId>jjwt-api</artifactId>  
    <version>0.11.5</version>  
</dependency>  
<dependency>  
    <groupId>io.jsonwebtoken</groupId>  
    <artifactId>jjwt-impl</artifactId>  
    <version>0.11.5</version>  
    <scope>runtime</scope>  
</dependency>  
<dependency>  
    <groupId>io.jsonwebtoken</groupId>  
    <artifactId>jjwt-jackson</artifactId>  
    <version>0.11.5</version>  
    <scope>runtime</scope>  
</dependency>

application.yml 추가

token:  
  expiration_time: 864000000  
  secret: my-256-bit-secret-key-which-is-long

JwtTokenProvider 생성

package com.example.UserService.security;  

import cohttp://m.example.UserService.dto.UserDto; 
import com.example.UserService.service.UserService;  
import io.jsonwebtoken.Jwts;  
import io.jsonwebtoken.SignatureAlgorithm;  
import io.jsonwebtoken.security.Keys;  
import jakarta.servlet.http.HttpServletResponse;  
import lombok.RequiredArgsConstructor;  
import org.springframework.beans.factory.annotation.Value;  
import org.springframework.security.core.Authentication;  
import org.springframework.security.core.userdetails.User;  
import org.springframework.stereotype.Component;  

import javax.crypto.SecretKey;  
import java.io.IOException;  
import java.nio.charset.StandardCharsets;  
import java.security.Key;  
import java.util.Date;  

@RequiredArgsConstructor  
@Component  
public class JwtTokenProvider {  

    private final UserService userService;  

    // application.yml에서 값 가져오기  
    @Value("${token.secret}")  
    private String jwtSecret;  

    @Value("${token.expiration_time}")  
    private long jwtExpirationInMs;  

    /**  
     * response에 JWT 토큰 추가  
     * @param response  
     * @param authentication  
     * @throws IOException  
     */    public void generateTokenAndAddToResponse(HttpServletResponse response, Authentication authentication) throws IOException {  
        User userDetails = (User) authentication.getPrincipal();  
        UserDto user = userService.getUserDetailsByEmail(userDetails.getUsername());  
        // JWT 토큰 생성  
        String token = generateToken(user.getUserId());  

        // HTTP 응답에 토큰과 사용자 ID를 헤더로 추가  
        response.addHeader("Authorization", "Bearer " + token);  
        response.addHeader("userId", user.getUserId());  
    }  

    /**  
     * JWT 토큰 생성  
     * @param userId  
     * @return  
     */  
    public String generateToken(String userId) {  
        SecretKey secretKey = Keys.hmacShaKeyFor(jwtSecret.getBytes());  

        return Jwts.builder()  
                .setSubject(userId)  
                .setIssuedAt(new Date())  
                .setExpiration(new Date(System.currentTimeMillis() + jwtExpirationInMs))  
                .signWith(secretKey, SignatureAlgorithm.ES256)  
                .compact();  
    }  
}

AuthenticationFilter 수정

package com.example.UserService.security;  

import com.example.UserService.service.UserService;  
import cohttp://m.example.UserService.vo.RequestLogin; 
import com.fasterxml.jackson.databind.ObjectMapper;  
import jakarta.servlet.FilterChain;  
import jakarta.servlet.ServletException;  
import jakarta.servlet.http.HttpServletRequest;  
import jakarta.servlet.http.HttpServletResponse;  
import lombok.extern.slf4j.Slf4j;  
import org.springframework.security.authentication.AuthenticationManager;  
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;  
import org.springframework.security.core.Authentication;  
import org.springframework.security.core.AuthenticationException;  
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;  

import java.io.IOException;  
import java.util.ArrayList;  

@Slf4j  
public class AuthenticationFilter extends UsernamePasswordAuthenticationFilter {  
    private UserService userService;  
    private JwtTokenProvider jwtTokenProvider;  

    public AuthenticationFilter(UserService userService  
            , AuthenticationManager authenticationManager, JwtTokenProvider jwtTokenProvider){  
        this.userService = userService;  
        super.setAuthenticationManager(authenticationManager);  
        this.jwtTokenProvider = jwtTokenProvider;  
    }  
    /**  
     * 사용자 인증  
     * @param request  
     * @param response  
     * @return  
     * @throws AuthenticationException  
     */    @Override  
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)  
            throws AuthenticationException {  
        try {  
            // request는 post 형태라 inputstream 으로 확인  
            RequestLogin creds = new ObjectMapper().readValue(request.getInputStream(), RequestLogin.class);  

            // 사용자 정보를 통해 인증처리  
            return getAuthenticationManager().authenticate(  
                    new UsernamePasswordAuthenticationToken(  
                            creds.getEmail(),  
                            creds.getPassword(),  
                            new ArrayList<>()  
                    )  
            );  
        }catch (IOException e){  
            throw new RuntimeException(e);  
        }  
    }  

    /**  
     * 인증성공 후 처리  
     * @param request  
     * @param response  
     * @param chain  
     * @param authentication  
     * @throws IOException  
     * @throws ServletException  
     */    @Override  
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response  
            , FilterChain chain, Authentication authentication) throws IOException, ServletException{  
        // JWT 토큰 생성 및 HTTP 응답에 추가  
        jwtTokenProvider.generateTokenAndAddToResponse(response, authentication);  
    }  
}

GatewayService 수정

pom.xml 수정

<dependency>  
    <groupId>io.jsonwebtoken</groupId>  
    <artifactId>jjwt-api</artifactId>  
    <version>0.11.5</version>  
</dependency>  
<dependency>  
    <groupId>io.jsonwebtoken</groupId>  
    <artifactId>jjwt-impl</artifactId>  
    <version>0.11.5</version>  
    <scope>runtime</scope>  
</dependency>  
<dependency>  
    <groupId>io.jsonwebtoken</groupId>  
    <artifactId>jjwt-jackson</artifactId>  
    <version>0.11.5</version>  
    <scope>runtime</scope>  
</dependency>
  • jwt 라이브러리 추가

AuthorizationHeaderFilter 생성

package cohttp://m.example.GatewayService.filter; 

import io.jsonwebtoken.Claims;  
import io.jsonwebtoken.Jws;  
import io.jsonwebtoken.JwtParser;  
import io.jsonwebtoken.Jwts;  
import io.jsonwebtoken.security.Keys;  
import lombok.extern.slf4j.Slf4j;  
import org.springframework.beans.factory.annotation.Value;  
import org.springframework.cloud.gateway.filter.GatewayFilter;  
import org.springframework.cloud.gateway.filter.factory.AbstractGatewayFilterFactory;  
import org.springframework.http.HttpHeaders;  
import org.springframework.http.HttpStatus;  
import org.springframework.http.server.reactive.ServerHttpRequest;  
import org.springframework.http.server.reactive.ServerHttpResponse;  
import org.springframework.stereotype.Component;  
import org.springframework.web.server.ServerWebExchange;  
import reactor.core.publisher.Mono;  

import javax.crypto.SecretKey;  
import java.nio.charset.StandardCharsets;  
import java.security.Key;  


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

    // application.properties 또는 application.yml에서 값 가져오기  
    @Value("${token.secret}")  
    private String jwtSecret;  

    public AuthorizationHeaderFilter() {  
        super(Config.class);  
    }  

    /**  
     * 필터링 로직을 정의 Authorization 헤더에서 JWT 토큰을 추출하여 검증한다.  
     * @param config  
     * @return  
     */  
    @Override  
    public GatewayFilter apply(Config config){  
        // Custom Pre Filter  
        return (exchange, chain) -> {  
            // ServletHttp 를 비동기 방식에서는 ServerHttp 로 사용한다.  
            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);  
        };  
    };  

    /**  
     * 에러 발생시 로그 출력  
     * @param exchange  
     * @param err  
     * @param httpStatus  
     * @return  
     */  
    private Mono<Void> onError(ServerWebExchange exchange, String err, HttpStatus httpStatus) {  
            ServerHttpResponse response = exchange.getResponse();  
            response.setStatusCode(httpStatus);  

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

    /**  
     * JWT 토큰의 유효성을 검사  
     * @param jwt  
     * @return  
     */  
    private boolean isJwtValid(String jwt) {  
        boolean returnValue = true;  
        String subject = null;  
        SecretKey secretKey = Keys.hmacShaKeyFor(jwtSecret.getBytes());  
        try {  
            // jwtSecret을 사용하여 JWT 파서 설정  
            Claims claims = Jwts.parserBuilder()  
                    .setSigningKey(secretKey)  
                    .build()  
                    .parseClaimsJws(jwt)  
                    .getBody();  

            subject = claims.getSubject();  

        } catch (Exception ex) {  
            returnValue = false;  
        }  

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

    public static class Config{  
        // Put the configuration properties  
    }  
}
  • JWT 토큰을 검증하는 커스텀 필터

application.yml

- id: UserService  
  uri: lb://UserService  
  predicates:  
    - Path=/userService/**  
    - Method=GET
  filters:  
    - RemoveRequestHeader=Cookie  
    - RewritePath=/userService/(?<segment>.*), /$\{segment} # /userService 패턴이 들어오면 제거해서 호출  
    - AuthorizationHeaderFilter # 인증 필터 추가

token:  
  secret: my-256-bit-secret-key-which-is-long
  • /userService/** 패턴의 url GET 방식 호출시 AuthorizationHeaderFilter를 통해 토큰을 인증
  • token secret 키 추가

결과

![[Pasted image 20240629105205.png]]

![[Pasted image 20240629104624.png]]

![[Pasted image 20240629125925.png]]

  • JWT 토큰 값을 넘겨 인증 받을 경우 url 정상 호출됨
반응형

+ Recent posts