데브코스 백엔드 3기 69일차

월화수!
Spring Security에서 DB기반으로 하는 인증처리에 대해 알아보았고,
stateful 환경에서 session을 사용할 수 있는 Spring Session과
stateless 환경에서 사용하는 JWT에 대해 공부했다!


일기(회고)

JPA 게시판 과제를 하다가 발견한 것

  • Jackson 라이브러리가 binding 해줄 때 기본생성자로 binding 해준다고 하는데,
    (리플렉션은 동적으로 객체를 생성해서 무조건 기본 생성자가 있어야 한다고함)
    나는 어떠한 DTO에도 기본생성자를 만들어주지 않았었다
    DTO를 불변객체로 설계하였기에
    필드에 다 final 키워드를 붙여주고 @RequiredArgsConstructor를 붙여주었다
    그런데 잘 돌아갔다 포스트맨에서 요청응답 모두 잘 바인딩이 되고
    rest docs도 잘 생성이 되었다
    왜 돌아가지????
    같이 찾아준 ㅇㅅ와 ㄷㅈ님께 고마움을 전한다 ㅎ
    • Java Reflection
      • 구체적인 클래스 타입을 알지 못해도, 그 클래스의 메소드, 타입, 변수들에 접근할 수 있도록 해주는 자바 API
      • 이미 로딩이 완료된 클래스에서 또 다른 클래스를 동적으로 로딩해서 Constructor, Member Variables, Member Method 등을 사용할 수 있도록 함
      • Reflection이 가져올 수 없는 정보 중 하나가 바로 생성자의 인자 정보들
      • 따라서 기본 생성자 없이 파라미터가 있는 생성자만 존재한다면 Reflection이 객체를 생성할 수 없음
    • 그런데 왜 돌아갔을까?
      • jackson-module-parameter-names : 기본 생성자가 없어도 역직렬화를 수행하게 도와주는 모듈
      • SpringBoot는 기본적으로 jackson bind 라이브러리를 가지고 있는데, 2.x 버전부터 jackson-module-parameter-names도 포함이 되었다고 함!
      • 단, 인자가 한개인 생성자만을 가진 형태에서는 binding error 발생할 수 있음
      • 그리고 컴파일환경을 Gradle에서 IntelliJ IDEA로 변경하였더니 에러가 났다
    • 결론
      • 인자 개수, 컴파일 환경 등에 따라 될 때와 안될 때가 있는거라면 실제 서비스를 운영하는 상황에서는 큰 문제니까 기본생성자를 만들어주자
      • final 키워드가 없더라도 setter 메서드를 생성하지 않는다면 불변처럼 나타낼 수 있다
      • 대신, private or protected로 빈객체 생성을 막아주자(리플렉션은 접근 제어자의 영향을 안받음)


  • 이번주는 크리스마스!
    로비에 생긴 크리스마스 트리와 함께 한컷 찍음ㅎ
    주말에 놀기위해 남은 날도 더 달리자아

    1222-1



Spring Security with Database

  • jdbc 의존성 추가 + application.yml 파일에 h2 db관련 설정
  • ignoring()에 h2-console 추가해주기
//Spring Security configure class에 추가
@Override
public void configure(WebSecurity web) {
    web.ignoring().antMatchers("/assets/**");
    web.ignoring().antMatchers("/assets/**", "/h2-console/**");
} 


  • DB관련 로깅을 콘솔에 찍기위해 만든 DataSourcePostProcessor.java
@Component
public class DataSourcePostProcessor implements BeanPostProcessor {

  @Override
  public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
    if (bean instanceof DataSource && !(bean instanceof Log4jdbcProxyDataSource)) {
      return new Log4jdbcProxyDataSource((DataSource) bean);
    } else {
      return bean;
    }
  }
}
  • BeanPostProcessor interface : 빈이 초기화 되기 전, 후 개입해서 빈을 직접적으로 조작할 수 있도록 할 수 있음
    • postProcessAfterInitialization() : 초기화된 후
  • 순수 DataSource라면 Log4jdbcProxyDataSource로 감싸주었음
    • 원래의 connection에는 로깅 남기는 기능이 없는데,
      Log4jdbcProxyDataSource의 getConnection()에서 로깅 남기는 기능이 있는 객체로 감싸주어서 그 기능을 부여한것



JdbcDaoImpl

//Spring Security configure class에 추가
@Bean
public UserDetailsService userDetailsService(DataSource dataSource) {
    JdbcDaoImpl jdbcDao = new JdbcDaoImpl();
    jdbcDao.setDataSource(dataSource);
    return jdbcDao;
}
  • DaoAuthenticationProvider에서 getUserdetailsService().loadUserByUsername(username)
    -> 사용자를 UserDetails 객체로 가져옴
    • UserDeatilsService의 구현체
      • InMemoryUserDetailsManager : default, 인메모리에서 사용자 인증정보 가져옴
      • JdbcDaoImpl(JdbcUserDetailsManager 상속받은 아이) : Jdbc Driver 통해서 DB에서 사용자 인증정보 가져옴
      • default인 InMemoryUserDetailsManager말고 JdbcDaoImpl를 빈으로 등록해서 사용해야 DB연동


  • 복습) 지난주 인증과정 중
    • AuthenticationManager의 구현체 ProviderManagerList<AuthenticationProvider> 가지고 있음
    • AuthenticationProvider 중 하나인 DaoAuthenticationProvider에서 UserDetailsService통해 In-memory or DB에서 UserDetails 객체 가져옴
    • UserDetails와 비교 후 맞으면 UsernamePasswordAuthenticationToken 반환



//Spring Security configure class에 추가
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
    auth.jdbcAuthentication()
            .dataSource(dataSource)
            .usersByUsernameQuery(//재정의 쿼리)
            .groupAuthoritiesByUsername(//재정의 쿼리)
            .getUserDetailsService().setEnableAuthorities(false)
    ;
}
  • 수행목적에 따라 SQL 쿼리 3개 정의해놓음
    => 이를 테이블 구조에 맞게 재정의해서 쓰기
    • userByUsernameQuery
    • groupAuthoritiesByUsernameQuery
    • authoritiesByUsernameQuery


사용자를 그룹기반(권한)으로 관리하기를 JPA버전으로

  • data-jpa 의존성 추가 + application.yml 파일에 jpa관련 설정

  • custom한 UserService.java(implements UserDetailsService)
    @Service
    public class UserService implements UserDetailsService {
    
      private final UserRepository userRepository;
    
      public UserService(UserRepository userRepository) {
          this.userRepository = userRepository;
      }
    
      @Override
      @Transactional(readOnly = true)
      public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
          return userRepository.findByLoginId(username)
                  .map(user ->
                          User.builder()
                                  .username(user.getLoginId())
                                  .password(user.getPasswd())
                                  .authorities(user.getGroup().getAuthorities())
                                  .build()
                  )
                  .orElseThrow(() -> new UsernameNotFoundException("Could not found user for " + username));
      }
    }
    
  • UserRepository.java
public interface UserRepository extends JpaRepository<User, Long> {
    //QueryDSL, fetch join
    @Query("select u from User u join fetch u.group g left join fetch g.permissions gp join fetch gp.permission where u.loginId = :loginId")  
    Optional<User> findByLoginId(String loginId);
}


//Spring Security configure class에 추가
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
    auth.userDetailsService(userService);
}
  • userDetailsService() : 커스텀한 UserDetailsService 설정하는



Spring Session

session 저장위한 외부스토리지를 추상화한 API
-> JDBC, Redis, Hazelcast, MongoDB 등 다양한 스토리지 활용가능

  • 현업에선 Redis, Hazelcast 등의 인메모리 스토리지 엔진 선호
    • why?
      • session객체는 사용빈도가 높은객체(Http 요청때마다 입출력이 발생함)
      • 따라서, 입출력 위한 스토리지엔진 성능이 우수할수록 전반적인 성능도 향상 -> 인메모리 스토리지 엔진 선호


3-Tier Architecture

  • presentation layer : 사용자와의 접점 제공(웹페이지)
  • application layer : 비즈니스 로직
  • data layer : 데이터 저장, 조회


  • 이로 인해
    • 프론트엔드, 백엔드 역할분리 -> 업무 효율화
    • application layer 확장용이 : 다른 계층에 미치는 영향 최소화
      • application layer의 서버 수평확장(scale-out) + 로드 밸런서 => 트래픽 분산 가능
      • 그런데 이 때, 한 서버에서 로그인했는데 이 서버에 문제가 생기면?
        인증 풀려서 로그인한 세션 없어짐
        => 해결 : 세션을 서버메모리에만 저장하는게 아니라, 세션클러스터라는 별도의 외부 스토리지에 저장



Spring Session 사용해서 JDBC기반 세션클러스터 구현하기

  • spring-session-jdbc 의존성 추가
    • 세션정보 저장을 위한 테이블 생성쿼리가 내부에 존재함(schema-h2.sql)
  • WebMVC configure class에 @EnableJdbcHttpSession
  • application.yml에 session관련 설정



Spring Session 동작과정

  • Spring Session의 핵심
    • SessionRepository
    • SessionRepositoryFilter


SessionRepository
Session의 CRUD 기능을 제공

  • 스토리지 종류에 따른 구현체들
    • MapSessionRepository : In-memory Map기반
    • JdbcIndexedSessionRepository : Jdbc기반 데이터 입출력처리(JdbcSession)
      • @EnableJdbcHttpSession annotation으로 생성됨
    • RedisIndexedSessionRepository : Redis기반
      • @EnableRedisHttpSession annotation으로 생성됨


SessionRepositoryFilter
모든 Http요청에 대해 동작
Servlet Filter

  • ServletRequest를 HttpServletRequest로 전환
  • HttpServletRequest/Response를 SessionRepositoryRequest/ResponseWrapper로 교체
    • SessionRepositoryRequestWrapper : HttpServletRequest의 session 처리와 관련된 처리를 override
    • Spring Security는 무슨 세션이 어디서 오는지 몰라도됨 HttpServletRequest 통해서 request 가져와서 요청의 HttpSession객체 가져올 뿐
  • SessionRepositoryRequestWrapper에서 getSession()하면 HttpSessionWrapper(HttpSession의 구현체) 얻음
    • HttpSessionWrapper 구현체는 Session 인터페이스 포함
    • 스토리지 종류에 따라 Session 인터페이스 구현체가 달라짐
    • getSession()통해 현재 세션 가져와서
      • null 아니면 가져온 그 세션 반환
      • null이면 getRequestedSession()
        -> httpsessionIdResolver 통해서 sessionId 가져오고
        그걸로 SessionRepository에서 session 조회
        (null이면 SessionRepository 통해서 생성)


  • SessionRepositoryFiltergetSession() -> return type : HttpSessionWrapper(쭉 타고 올라가면 결국 HttpSession)
    • HttpSession의 getSession()을 override 한것 -> return type : HttpSession


=> 결론 : Spring Security는 Spring Session과 관련해서 아무것도 바꿀게 없다
= Spring Security는 Spring Session과 독립적으로 동작한다
Spring은 추상화된 API로 통합되어 있다

  • Session관련 동작은 Spring Session 구현체가 처리
  • Session CRUD는 SessionRepository 구현체가 처리



JWT(Json Web Token)

Stateless architecture

  • Http Session을 써야하는가?는 서비스 성격에 따라 고민이 필요함
    • Http Session을 사용한다 = 서비스가 stateful 하다
      • 장점 : 보안관련(단일 사용자의 다중로그인 control, 인증된 사용자의 유효성 지속적으로 확인가능, 필요에 따라 강제 로그아웃)
      • 단점 : application layer 수평확장 시 세션클러스터라는 별도의 component 필요(계속 신경써줘야함)
    • <-> Http Session을 사용하지 않는다 = 서비스가 stateless 하다
      • 서버가 아무런 상태도 안가짐 => 수평확장이 쉬움
      • stateful의 장점이 stateless의 단점이 됨 -> 정적 리소스에 적합
      • 그러나, 우리가 만드는 서비스는 사용자식별 필요! => 해결 : JWT 사용!



JWT를 이용한 클라이언트 인증 & 요청처리 flow

  1. 클라이언트 -> 서버 : 인증처리요청(로그인)
  2. 서버 -> 클라이언트 : 처리결과에 JWT값 포함해서 응답
  3. 클라이언트 : 저장해두고 모든 api 요청을 할 때마다 Http Header에 JWT 포함 (URL에대해 안전한 문자열로 구성되어 있기 때문에 어디든 포함 가능하지만, 주로 헤더에 함)
  4. 서버는 JWT 추출해서 해당 JWT 통해 어떤 클라이언트인지 식별 후 api 기능 수행



SessionId와 차이점

  • sessionId : 서버에서 단지 세션식별위해서만 만든 아무 의미없는 값(랜덤 생성)
  • JWT : Json포맷 사용해서 어떤 데이터를 표현하는 값



JWT 구조

Header

  • type이 JWT다라는것
  • 해당 JWT가 어떤 알고리즘으로 서명되었는지에 대한 알고리즘 정보


Payload
JWT통해서 실질적으로 전달하고자 하는 데이터 = Claim-set이라고도 함

  • iss : 토큰발급자
  • iat : 발급시각
  • exp : 만료시각
  • 사용자의 정보(권한)도 포함 가능 -> custom claims
  • JWT 자체가 암호화되는게 아니기 때문에(단지 Base64로 인코딩 되었을 뿐임)
    • 연락처, 비밀번호 등의 민감정보 포함되면 안됨


Signature
JWT의 위변조 검증을 위한 데이터

HMACSHA256(
  base64UrlEncode(header) + "." +
  base64UrlEncode(payload),
  secret)
  • pseudo-code 슈도코드로 나타냄
  • 서버만 알고있는 비밀키(맨마지막 줄 secret)를 이용해서 헤더에 정의된 알고리즘으로 서명값을 생성하고 있음
    = 비밀키 없으면 서명데이터 올바르게 생성할 수 없음



JWT의 장단점

stateless의 장단점과 유사

  • 장점
    • 스토리지 필요없음
    • 수평확장 쉬움
    • active user 많으면 유리(session 이용 시 user수만큼 세션저장 - 스토리지 관리 어려움)
  • 단점
    • 모든 Http 요청에 JWT 포함해야하니까 JWT 크기 최대한 작게해야함
    • 보안면에서 불리 : 만료기간 남은 JWT가 외부에 유출된 경우 만료처리 어려움
      -> JWT 만료기간 짧게 설정해야함



JWT 사용해서 HttpSession 사용하지 않는 Rest API 만들기

  • java-jwt 의존성 추가
    • java언어로 JWT 생성하거나 JWT 검증하는 기능 제공하는 라이브러리
  • application.yml에 JWT관련 설정들 작성 + session 관련 설정들 지우기
jwt:
  header: token
  issuer: prgrms
  client-secret: EENY5W0eegTf1naQB2eDeyCLl5kRS2b8xa5c4qLdS0hmVjtbvo8tOyhPMcAmtPuQ
  //알고리즘을 HMAC512사용하는데 이 때 64바이트의 client-secret 
  expiry-seconds: 60
  • Jwt configuration class : yml에 적어둔 JWT 관련 설정들 바인딩하는 클래스

  • 만든 Jwt.java

    • 만든 Claims.java : JWT 통해 전달할 데이터 만드는
      @Getter
      public final class Jwt {
      
      private final String issuer;
      private final String clientSecret;
      private final int expirySeconds;
      private final Algorithm algorithm;
      private final JWTVerifier jwtVerifier;
      
      public Jwt(String issuer, String clientSecret, int expirySeconds) {
          this.issuer = issuer;
          this.clientSecret = clientSecret;
          this.expirySeconds = expirySeconds;
          this.algorithm = Algorithm.HMAC512(clientSecret);  //HMAC512 알고리즘에는 64바이트 client-secret 필요(yml파일에 적어둠)
          this.jwtVerifier = com.auth0.jwt.JWT.require(algorithm)
                  .withIssuer(issuer)
                  .build();
      }
      
      public String sign(Claims claims) {   //토큰 만들어주는 메서드, 토큰을 만들 때 필요한 데이터들(claims) 받아서
          Date now = new Date();  //일반적으로는 Date type보단 LocalDateTime이 더 좋지만 java-jwt에서 시간관련은 Date type 사용
          JWTCreator.Builder builder = com.auth0.jwt.JWT.create();
          builder.withIssuer(issuer);
          builder.withIssuedAt(now);
          if (expirySeconds > 0) {
              builder.withExpiresAt(new Date(now.getTime() + expirySeconds * 1_000L));
          }
          builder.withClaim("username", claims.username);
          builder.withArrayClaim("roles", claims.roles);
          return builder.sign(algorithm);  //위변조 검증위한 signature를 주어진 알고리즘으로 생성해서 토큰생성
      }
      
      public Claims verify(String token) throws JWTVerificationException {   //토큰이 주어졌을 때 토큰을 디코드해서 claims로 반환하는
          return new Claims(jwtVerifier.verify(token));   //jwtVerifier.verify()에서 위변조 검증
      }
      
      static public class Claims {
          String username;  //custom claims
          String[] roles;
          Date iat;  //reserved claims
          Date exp;
      
          private Claims() {/*no-op*/}
      
          Claims(DecodedJWT decodedJWT) {   //DecodedJWT 통해서만 Claims 객체 초기화
      
              Claim username = decodedJWT.getClaim("username");
              if (!username.isNull())
                  this.username = username.asString();
      
              Claim roles = decodedJWT.getClaim("roles");
              if (!roles.isNull()) {
                  this.roles = roles.asArray(String.class);
              }
      
              this.iat = decodedJWT.getIssuedAt();
              this.exp = decodedJWT.getExpiresAt();
          }
      
          public static Claims from(String username, String[] roles) {  //팩토리메서드
              Claims claims = new Claims();
              claims.username = username;
              claims.roles = roles;
              return claims;
          }
      
          public Map<String, Object> asMap() {
              Map<String, Object> map = new HashMap<>();
              map.put("username", username);
              map.put("roles", roles);
              map.put("iat", iat());  //Date type이 아닌 long type의 timestamp로 변경해서 넣어줌
              map.put("exp", exp());
              return map;
          }
      
          long iat() {
              return iat != null ? iat.getTime() : -1;
          }
      
          long exp() {
              return exp != null ? exp.getTime() : -1;
          }
      
          void eraseIat() {
              iat = null;
          }
      
          void eraseExp() {
              exp = null;
          }
      
          @Override
          public String toString() {
              return new ToStringBuilder(this, ToStringStyle.SHORT_PREFIX_STYLE)
                      .append("username", username)
                      .append("roles", Arrays.toString(roles))
                      .append("iat", iat)
                      .append("exp", exp)
                      .toString();
          }
      }
      }
      


업데이트: