데브코스 백엔드 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로 빈객체 생성을 막아주자(리플렉션은 접근 제어자의 영향을 안받음)
- Java Reflection
-
이번주는 크리스마스!
로비에 생긴 크리스마스 트리와 함께 한컷 찍음ㅎ
주말에 놀기위해 남은 날도 더 달리자아
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()에서 로깅 남기는 기능이 있는 객체로 감싸주어서 그 기능을 부여한것
- 원래의 connection에는 로깅 남기는 기능이 없는데,
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
의 구현체ProviderManager
가List<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 요청때마다 입출력이 발생함)
- 따라서, 입출력 위한 스토리지엔진 성능이 우수할수록 전반적인 성능도 향상 -> 인메모리 스토리지 엔진 선호
- why?
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 통해서 생성)
SessionRepositoryFilter
의getSession()
-> 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 사용!
- Http Session을 사용한다 = 서비스가 stateful 하다
JWT를 이용한 클라이언트 인증 & 요청처리 flow
- 클라이언트 -> 서버 : 인증처리요청(로그인)
- 서버 -> 클라이언트 : 처리결과에 JWT값 포함해서 응답
- 클라이언트 : 저장해두고 모든 api 요청을 할 때마다 Http Header에 JWT 포함 (URL에대해 안전한 문자열로 구성되어 있기 때문에 어디든 포함 가능하지만, 주로 헤더에 함)
- 서버는 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(); } } }
- 만든 Claims.java : JWT 통해 전달할 데이터 만드는