앞으로 구현할 유저 서비스의 개요입니다.
APIs 입니다.
기능 | URI (API GW 사용 시) | URI (API Gateway 미사용 시) | HTTP Method |
사용자 로그인 | /user-service/login | /login | POST |
프로젝트는 이전에 사용하던 User-Service를 가져옵니다.
회원 로그인 구성은 아래와 같습니다.
- 사용자로부터 email과 password를 받습니다.
- attempAuthentication() 메소드로 인증을 시작합니다.
- UsernamePasswordAuthenticationToken으로 email과 password를 토큰으로 만들어 줍니다.
- loadUserByUsername() 메소드에서 usename( email과 동일 )로 데이터베이스에서 사용자 정보를 가져와 User객체로 만듭니다.
- DB에서 가져온 데이터와 사용자가 입력하여 토큰화된 데이터를 비교합니다.
- 인증이 완료되면 successfulAuthentication() 메소드에서 user_id를 토큰화 하고반환합니다.
- user_id를 가져오기 위해서 username( email과 동일 )으로 DB를 조회하여 유저 정보를 가져옵니다.
- 가져온 유저 정보에서 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 정보
'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 |