Users Microservice 개요
APIs
기능 | URI(API Gateway 사용 시) | URI(API Gateway 미사용 시) | HTTP Method |
사용자 로그인 | /user-service/login | /login | POST |
사용자 정보 수정 삭제 | 추후 구현 개인적으로 구현 |
인증
user-service
RequestLogin.class 생성
@Data
public class RequestLogin {
@NotNull(message = "Email cannot be null")
@Size(min = 2, message = "Email not be less than 2 characters")
@Email
private String email;
@NotNull(message = "Password cannot be null")
@Size(min = 8, message = "Password must be equals or greater than 8 characters")
private String password;
}
- AuthenticationFilter.java
Spring Secutiry를 이용한 로그인 요청 발생 시 작업을 처리해 주는 Custom FIlter 클래스
참고링크 - Class AbstractAuthenticationProcessingFilter
UsernamePasswordAuthenticationFilter 상속(extends)
attemptAuthentication(), successfulAuthentication() Override받기(윈도우: alt + ins)
public class AuthenticationFilter extends UsernamePasswordAuthenticationFilter {
// # 로그인을 시도할 때
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
try {
RequestLogin creds = new ObjectMapper().readValue(request.getInputStream(), RequestLogin.class);
return getAuthenticationManager().authenticate( // 로그인한 creds값과 변경된 token값을 비교해주겠다.
new UsernamePasswordAuthenticationToken(creds.getEmail(),creds.getPassword(), new ArrayList<>())
// Token으로 변경, new ArrayList<>(): 어떤 권한을 줄 것인지
);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
// # 로그인 성공했을 때 (ex.값반환, 토큰만료시간 등등)
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
super.successfulAuthentication(request, response, chain, authResult);
}
}
💡 ObjectMapper().readValue(request.getInputStream(), RequestLogin.class)
전달 되어진 inputStream()에 어떤 값이 들어가 있으면, 그 값은 java클래스 타입(RequestLogin.class)으로 변경해라.
request안의 inputStream을 통채로 변경시켜 줄건데, inputStream으로 받는 이유는 우리가 전달하고자 하는 로그인의 값은 POST형태로 전달도니다.
POST형태로 전달되는 것은 request 파라미터로 받을 수 없기 때문에 앞에 있는 것처럼 inputStream()으로 처리해주면 수작업으로도 데이터가 어떤게 들어왔는지 처리할 수있다.
추가 궁금한 사항 링크 : ObjectMapper와 ModelMapper
💡 Class UsernamePasswordAuthenticationToken
참고 링크 - Class UsernamePasswordAuthenticationToken
inputStream으로 넘어온 데이터를 가지고 인증정보를 만들기 위해 UsernamePasswordAuthenticationFilter에 전달을 해야한다. 이 값을 사용하기 위해서는 사용자가 입력했던 이메일과 아이디 값을 Spring Security에서 사용할 수 있는 형태의 값으로 변환하기 위해서
앞에 있는 것처럼 username, password, authentication token이라는 형태로 바꿔줄 필요가 있다.
- WebSecurity.java
사용자 요청에 대해 AuthenticationFilter를 거치도록 수정
@Configuration // 다른 빈들보다 먼저 추가
@EnableWebSecurity // WebSecurity 용도이다
public class WebSecurity extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable(); // csrf 사용하지 않겠다.
// http.authorizeRequests().antMatchers("/users/**").permitAll(); // authorize Requests허용할 수 있는 작업은 "/users/**"에 Match되면 PermitAll해라
http.authorizeRequests().antMatchers("/**") // 모든 요청에 대해
.hasIpAddress("192.168.0.106")// 해당 IP만
.and()
.addFilter(getAuthencation());
http.headers().frameOptions().disable(); // 헤더의 프레임옵션을 사용하지 않겠다
}
}
getAuthentication() 필터 생성
타입을 Filter에서 전에 만들어둔 AuthentiationFilter로 바꿔주자.
AuthentiationFilter가 Filter를 상속받았기 때문에 사용 가능하다.
private AuthenticationFilter getAuthentication() {
}
인증 처리를 위한 configure() 메소드 생성
AuthenticationManagerBuilder를 상속받은 Configure메소드 override하기
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
super.configure(auth);
}
@Configuration // 다른 빈들보다 먼저 추가
@EnableWebSecurity // WebSecurity 용도이다
public class WebSecurity extends WebSecurityConfigurerAdapter {
private BCryptPasswordEncoder bCryptPasswordEncoder;
private UserService userService;
private Environment env;
public WebSecurity(BCryptPasswordEncoder bCryptPasswordEncoder, UserService userService, Environment env) {
this.bCryptPasswordEncoder = bCryptPasswordEncoder;
this.userService = userService;
this.env = env;
// .. 생략
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userService).passwordEncoder(bCryptPasswordEncoder);
}
}
위에 처럼 코드를 작성하고 나면 userService에 빨간 줄이 뜬다.
왜?
userDetailsService에 들어가 보면 userDetailsService를 상속받은 클래스여야 한다고 쓰여있다.
- UserService
UserService로 가서 userDetailsService를 상속 받아준다.
하지만 그래도 에러가 뜬다.
왜?
상속은 받았지만 상속받은 걸 구현하지 않았기 때문.
- UserServiceImpl
실질적인 로직이 있는 UserServiceImpl로 간다.
implements하여 상속받아준다.
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
UserEntity userEntity = userRepository.findByEmail(username); // username이 email임..
if (userEntity == null)
throw new UsernameNotFoundException(username);
// return : 로그인이 모두 통과되었을 때 진행, new ArrayList<>() : 권한리스트
return new User(userEntity.getEmail(), userEntity.getEncryptedPwd(), true, true, true, true, new ArrayList<>());
}
return에 User 객체를 생성
여기서 User는 우리가 만든 것이 아니라, 스프링, 시큐리티에 포함되어 있는 클래스이다.
User의 파라미터는 어떤게 들어가는지 살펴보자.
public User(String username, String password, boolean enabled, boolean accountNonExpired, boolean credentialsNonExpired, boolean accountNonLocked, Collection<? extends GrantedAuthority> authorities) {
Assert.isTrue(username != null && !"".equals(username) && password != null, "Cannot pass null or empty values to constructor");
this.username = username;
this.password = password;
this.enabled = enabled;
this.accountNonExpired = accountNonExpired;
this.credentialsNonExpired = credentialsNonExpired;
this.accountNonLocked = accountNonLocked;
this.authorities = Collections.unmodifiableSet(sortAuthorities(authorities));
}
ApiGateway-service
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=Cookies
- RewritePath=/user-service/(?<segment>.*), /$\{segment}
💡 RemoveRequestHeader=Cookies- remove
- 요청이 되어 있는 header값을 삭제.. → POST로 전달되는 데이터 값을 매번 새로운 데이터처럼 인식하기 위해서 request header값을 초기화
remove request header를 해 주는 목적은 요청시 특정 헤더 값을 제거하거나 사용하지 않기 위함입니다.
설정에 있는 것처럼 Cookie라는 정보를 Request Header에서 제외하고 전달하기 위해 설정하시면 됩니다. Postman이라는 프로그램으로 어떤 API(Http GET METHOD)호출 할 때, 추가 설정을 하지 않아도 아래와 같은 Header가 같이 전달됩니다.
이러한 부분을 전달하지 않기 위해 사용한다고 보시면 좋을 것 같습니다.
💡 RewritePath=/user-service/(?<segment>.*), /$\{segment}
- /user-service/{?<segment>.*}를 /$\{segment}로 변경
- user-sevice에 요청되는 정보값을 filter적용하여 uri를 다시 작성해서 전달한다.
서버 실행
로그인을 컨트롤러에 구현하지 않았지만 스프링 시큐리티를 사용한다고 선언하면 로그인은 기본으로 제공된다.(.../login)
postman으로 확인해보자
test 성공
gateway를 통해 test해보자.
test 성공
비밀번호를 틀려보자
error: Unauthorized (권한X)
user-service
user-service로 다시 돌아와서 AuthenticationFilter가 잘 작동되고 있는지 확인해보자
디버그 모드를 사용하여 값이 잘 들어왔는지 확인
잘 들어왔다!
(로그인 처리 과정) 어떤 순서로 함수가 실행되는지 확인
@Configuration가 달려있는 WebSecurity와 관련된 클래스들이 메모리에 올라간다. 빈으로 등록된다.
AuthenticationFilter는 사용자가 로그인을 시도하면 가장 먼저 실행된다.
로그인 시도 -> AuthenticationFilter의 attemptAuthentication -> UserServiceImpl의 loadUserByUsername -> (로그인 성공이라면) AuthenticationFilter의 successfulAuthentication
로그인 성공 처리
AuthenticationFilter.java
public class AuthenticationFilter extends UsernamePasswordAuthenticationFilter {
private UserService userService;
private Environment env; // 왜 필요? 토큰에 대한 만료기간, 토큰을 만들기 위해서 특정한 키워드를 넣어서 그 키를 이용해서 알고리즘을 사용할 때 .yml에 넣는다.
public AuthenticationFilter(AuthenticationManager authenticationManager, UserService userService, Environment env) {
super.setAuthenticationManager(authenticationManager);
this.userService = userService;
this.env = env;
}
// ...생략
// # 로그인 성공했을 때 (ex.값반환, 토큰만료시간 등등)
@Override
protected void successfulAuthentication(
HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult)
throws IOException, ServletException {
// 여기서의 User는 import org.springframework.security.core.userdetails.User;
// 프레임워크에 있는거임, 결과값 바든 용(UserServiceImpl의 loadUserByUsername()서 확인 )
String userName = ((User)authResult.getPrincipal()).getUsername();
UserDto userDetails = userService.getUserDetailsByEmail(userName);
}
}
WebSecurity.java
authenticationFilter.setAuthenticationManager(authenticationManager());
이거는 지워준다.
AuthenticationFilter()에서 생성자를 주입해줬기 때문에 여기서 안해줘도 된다.
@Configuration // 다른 빈들보다 먼저 추가
@EnableWebSecurity // WebSecurity 용도이다
public class WebSecurity extends WebSecurityConfigurerAdapter {
private BCryptPasswordEncoder bCryptPasswordEncoder;
private UserService userService;
private Environment env;
public WebSecurity(BCryptPasswordEncoder bCryptPasswordEncoder, UserService userService, Environment env) {
this.bCryptPasswordEncoder = bCryptPasswordEncoder;
this.userService = userService;
this.env = env;
}
// ...생략
private AuthenticationFilter getAuthenticationFilter() throws Exception {
AuthenticationFilter authenticationFilter = new AuthenticationFilter(authenticationManager(), userService, env);
// authenticationFilter.setAuthenticationManager(authenticationManager()); // Spring Security에서 가져온 매니저를 가지고 인증처리 하겠다.
return authenticationFilter;
}
// ...생략
}
successfulAuthentication에 로그인이 잘되는지 디버그를 걸고 디버그 모드로 실행해보자
디버그 모드로 실행하면, console에서 확인 할 수 있다.
pwd와 encryptedPwd 값이 같냐고 물어봤는데 값이 true로 나왔다. 그럼 로그인 성공이겠지..
Security 순서 정리
1. 스프링부트 실행되면 @configur 있는 곳 먼저 메모리에 다 올라감 ( 이 곳엔 시큐리티 설정들이 들어가있음 )
2. 유저가 정보를 입력하고 로그인하면 attemptAuthentication 메소드가 젤 먼저 실행됨 이 곳에서 유저가 입력한 정보를 UsernamePasswordAuthenticationToken
라는 메소드로 token형태로 바꿈(jwt아님 그냥 시큐리티 에서 쓰는 토큰)
3. loadUserByUserName 에서 username은 사용자가 입력한 정보가 들어가게 됨. (token으로 만들었던 정보 중 getEmail) -> 그걸 가지고 내 repository에서 데이터를 찾아옴 -?
존재한다면 -> 그 정보를 토대로 시큐리티 안에 포함되어 있는 User모델을 만듬 -> 그 만든 데이터를 successful 로 넘김
4. successful -> (뒷부분) 방금 넘긴 User 데이터를 가지고 token을 발행할거임 -> 근데 email말고 uuid로 토큰을 발행할거라서 ->
getUserDetailsByEmail 를 이용해서 email로 실제 db에 있는 데이터를 가져와서 그 안에 있는 uuid를 쓸거임 -> 그걸로 jwt토큰 만들어서 client에 header로 보낼거임
### 시큐리티 기동과정
1. 먼저 @Configuragion이라고 붙은 클래스들이 메모리에 올라간다(여기선 websecurity.class)
2. 그 다음 AuthenticationFilter extends UsernamePasswordAuthenticatinfilter.class 가 실행된다
3. 여기서 atttemptAuthentcation()이 실행됨
4. 그 다음 UsernamePasswordAuthenticationToken으로 자바언어로 변경(email, password를 변경)
5. UserDetailService클래스의 loadUserByUsername()메서드가 실행됨(UserServiceImpl.class)
6. 여기서 repository에서 findbyEmail로 해당 유저를 db에서 검색함
7. 그리고 이 값을 User객체로 변환
8. 성공했으면 AuthenticationFilter의 successfulAuthentication()이 실행됨
9. successfulauthenticaion메서드에서 jwt를 생성함
10. 그리고 이 jwt를 클라이언트에 반환
권한
JWT
apigateway-service
pom.xml에 Denqendency 추가
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
application.yml
token:
expiration_time: 86400000 # 하루 60초*(60분*24시간)*1000
secret: user_token # 어떤 키를 가지고 생성할 것인지(임의값 넣기)
AuthenticationFilter
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
// super.successfulAuthentication(request, response, chain, authResult);
// log.debug(((User)authResult.getPrincipal()).getUsername());
// import org.springframework.security.core.userdetails.User;
//생성된 user객체에서 정보를 빼옴 -> 캐스팅해서 빼옴
String userName = ((User)authResult.getPrincipal()).getUsername();
UserDto userDetails = userService.getUserDetailsByEmail(userName);
String token = Jwts.builder()
.setSubject(userDetails.getUserId()) // getUserId로 token만들 것이다
.setExpiration(new Date(System.currentTimeMillis() +
Long.parseLong(env.getProperty("token.expiration_time")))) // 하루짜리 토큰 : 현재 시간 + 하루(application.yml에서 적어둔 expiration_time값 가져오기) (.yml에서 가져온 값은 숫자처럼 보여도 String이다.)
.signWith(SignatureAlgorithm.HS512, env.getProperty("token.secret")) // 암호화하기 위해 시그니처 알고리즘 추가
.compact(); // 토근 완성!!!!!!
response.addHeader("token", token); // Header에 token 넣기
response.addHeader("userId", userDetails.getUserId());
}
디버그를 걸어 token값을 확인해보자
postaman에서 Headers에 들어가보면 token값이 잘 나온다.
Header에 userId도 잘 나오고 있다.
JWT 처리 과정
전통적인 인증 시스템
Cookie, Session 사용
문제점
- 세션과 쿠키는 모바일 애플리케이션에서 유효하게 사용할 수 없음(공유 불가) ex. React와 Java의 세션공유 불가
- 렌더링된 HTML페이지가 반환되지만, 모바일 애플리케이션에서는 JSON(or XML)과 같은 포맷 필요
Token 기반 인증 시스템 (JWT도 여기에 포함)
Token 사용
- 공유가능
💡 Token (JSON Web Token)
- https://jwt.io/
- 인증 헤더 내에서 사용되는 토큰 포맷
- 두 개의 시스템끼리 안전한 방법으로 통신 가능
- PAYLOAD로 값을 전달하게 되면 이걸 받았던 쪽은 굳이 Spring Framework에서 발급된 토큰인지 아닌지 그걸 떠나 이 토큰이 정상적인지 아닌지만 판단한다고 하면 인증 서비스를 유효하게 처리할 수 있다.
장점
- 클라리언트 독립적인 서비스(stateless)
- CDN(Cilent Delivery Network) : 중간 단계에서 캐시 서브를 놓을 수 있다. 캐시서버로 인증 처리
- No Cookie-Session (No CSRF, 사이트간 요청 위조 낮아짐)
- 지속적인 코튼 저장
다양한 언어를 지원하니 홈페이지에서 확인 후, 디펜던시 추가 후 사용해보자
AuthorizationHeaderFilter 추가
1. API Gateway service에 Spring Security와 JWT Token 사용 추가
apigateway-service
Dependency 추가
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
JDK 11 버전을 사용할 경우, jwt 라이브러리를 추가하는 것만으로는 토큰을 사용할 때 필요한 라이브러리가 모두 import되지 않기 때문에, javax.xml.bind를 종속성에 추가해주어야 한다.
<dependency>
<groupId>javax.xml.bind</groupId>
<artifactId>jaxb-api</artifactId>
<version>2.3.1</version>
</dependency>
AuthorizationHeaderFilter.java 생성
apply() Override 받기(alt + ins)
@Component
@Slf4j
public class AuthorizationHeaderFilter extends AbstractGatewayFilterFactory<AuthorizationHeaderFilter.Config> {
Environment env;
public AuthorizationHeaderFilter(Environment env) {
this.env = env;
}
// 설정에 관련되어 있는 작업 전담
public static class Config {
}
@Override
public GatewayFilter apply(Config config) {
return null;
}
}
login -> token -> user(with token) -> header(include token)
login을 하면 token을 반환받는다.
클라이언트에서 사용자의 정보를 요청할 때 token정보를 요청할 것이다.
그러면 서버 측에서는 token 정보를 열어봐서 그 token이 맞는지 아닌지 판단을 한다.
해당하는 token은 header 안에 있다.(그래서 먼저 헤더가 있는지 검사를 했다.if (!request.getHeaders().containsKey(HttpHeaders.AUTHORIZATION)))
token이 유효한건지 검사
subject는 디코드한 것이다. 여기서 나온 userId와 진짜 userId랑 같은지 비교
💡 Mono, Flux
💡 Mono, Flux
gateway는 기존에 알던 Spring MVC로 구성하지 않는다.(HTTP Servlet request/response 사용하지 않는다.)
Spring WebFlux로 구성한다. Fuctional API를 쓰면서 비동기 방식으로 처리하게 된다.
Spring5.0에서 추가된 프레임워크
WebFlux 안에서 클라이언트 요청이 들어왔을 때 데이터를 처리하는 두가지 단위 중 하나가 Mono
1. 단일값 반환할 때 : Mono
2. 단일값이 아닌 값 반환할 때(여러가지 값): Flux
isJwtValid(), onError()는 먼저 작성만 해두고 alt+ins로 하는게 편하다..
@Component
@Slf4j
public class AuthorizationHeaderFilter extends AbstractGatewayFilterFactory<AuthorizationHeaderFilter.Config> {
Environment env;
public AuthorizationHeaderFilter(Environment env) {
this.env = env;
}
// 설정에 관련되어 있는 작업 전담
public static class Config {
}
// login -> token -> user(with token) -> header(include token)
@Override
public GatewayFilter apply(Config config) {
return ((exchange, chain) -> {
ServerHttpRequest request = exchange.getRequest(); // step1. 토큰 받기
if (!request.getHeaders().containsKey(HttpHeaders.AUTHORIZATION)) {
return onError(exchange, "No authorization header", HttpStatus.UNAUTHORIZED); //onError(반환값,메시지,HttpStatus)
}
//authorizationHeader에는 Bearer토큰 저장
String authorizationHeader = request.getHeaders().get(HttpHeaders.AUTHORIZATION).get(0); // 앞의 get의 반환값이 List이기 때문에 0번째 값을 가져오겠다.(get(0))
String jwt = authorizationHeader.replace("Bearer", ""); // "Bearer"를 제외한게 token
if (!isJwtValid(jwt)) {
return onError(exchange, "No authorization header", HttpStatus.UNAUTHORIZED);
}
return chain.filter(exchange); // step2. 통과 메시지
});
}
private boolean isJwtValid(String jwt) {
boolean returnValue = true;
String subject = null;
try {
subject = Jwts.parser().setSigningKey(env.getProperty("token.secret")) // 토큰 암호화한거 풀기
.parseClaimsJws(jwt).getBody() // 복호화 대상 : jwt, parseClaimsJws: 토큰을 문자형 데이터값으로 파싱
.getSubject(); // 그중에서 subject값만 가져옴
return returnValue;
} catch (Exception e) { // 파싱하다 오류날 수도 있음
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(); // ServletResponse 아님
response.setStatusCode(httpStatus);
log.error(err);
return response.setComplete();
}
}
application.yml
파싱할 때 사용하기 위해서 암호화했던 값을 넣어주자
token:
secret: user_token # 어떤 키를 가지고 생성할 것인지(임의값 넣기)
로그인은 토큰을 받는 거기 때문에 패스.
웰컴, 헬스체크, 목록보기, 개인 사용자 상세보기 등 정보 조회(GET) 에만 필터 추가
Test
디버그 모드로 돌려보자
에러 뜸.
ERROR 2880 --- [apigateway-service] [ main] o.s.c.gateway.route.CachingRouteLocator : Refresh routes error !!!
java.lang.ClassCastException: class java.lang.Object cannot be cast to class com.example.apigatewayservice.filter.AuthorizationHeaderFilter$Config (java.lang.Object is in module java.base of loader 'bootstrap'; com.example.apigatewayservice.filter.AuthorizationHeaderFilter$Config is in unnamed module of loader 'app')
Config 정보를 부모 클래스에 전달해줘야 한다.
다시 서버 기동해보자.
사용자의 상세보기 등 GET을 할 때 인증정보에 아무것도 전달하지 않으면 오류가 뜬다(401: 클라이언트 데이터 사용할 수 없다.)
여기에 걸려서 401 에러뜸
인증정보를 넣고 요청해야 성공한다.
token을 복사하자
정보를 조회할 때 아래처럼 Authotization에 token값을 넣어준다.
Type을 No Auth 또는 Inherit auth from parent로 바꿔줘야 한다.
❓ 특정 uri에는 AuthorizationFilter를 적용하고 싶지 않다면?
특정 uri 지정해주고 필터빼주면 된다.