- JWT란 Json Web Token의 줄임말 이다.
- Header,Payload,Signature 3개의 부분으로 구성 됨
- 헤더 : 시그니처를 해싱하기 위한 알고리즘 정보들이 담겨 있음
- 페이로드 : 말그대로
- 시그니처 : 토큰의 유효성 검증을 위한 문자열
- 기존에 Thymeleaf를 템플릿 엔진으로 사용해서 SSR(Server Side Rendering)을 할 때에는 클라이언트와 서버가 분리되어 있지 않았기에 JWT를 이용하지 않고 세션에 보안 정보를 저장하였다.
- 그렇기에 타임리프의 security태그도 사용할 수 있었다.
- 하지만, 이렇게 SSR을 해서 세션에 보안 정보를 저장하게 된다면 CSRF공격이 가능하게 된다
- CSRF : Cross Site Request Forgery
- 공격자의 사이트에 접속한 사용자가 CORS가 적용된 부분(img,link 등등)에 공격자의 의도가 담긴 Parameter( 비밀번호 바꾸는 api에 공격자가 원하는 비밀번호를 담는 것과 같은..)를 담아 공격자가 공격할 서버에 전송하게 끔 하는 것
- 방어책
- referrer : 공격자의 사이트와 공격 대상 서버의 주소가 다를 것이므로 서버의 주소와 다른 주소에서의 호출을 모두 차단하는 형태,
- 근데 이러면 어떤 사이트에서도 서버를 호출할 수 없고 , MSA(micro service architecture)의 경우 모든 서비스에 대한 host ip를 따로 관리해 주어야 한다
- MSA: 어플리케이션의 모든 구성 요소를 담는 모놀리딕 아키텍처의 반대 개념
- 하나의 micro service개발을 소수의 인원이 담당하기에 서비스 별 배포가 가능하고, 확장성이 좋으며, 장애처리 및 격리가 쉽고, 유지 보수가 용이하며, 신기술의 적용이 쉽고 polyglot하게(여러 프로그래밍 언어, 패러다임을 사용하며) 개발할 수 있다.
- 단점으로는 , 서비스 간 호출 시 api를 사용해야 하므로 비용이 증가하고, 테이터가 분산되어 있기에 관리가 힘들며 트랜잭션을 구현하기 까다롭다, unit test는 용이하지만 integration테스트 같은 큰 단위의 테스트의 경우 여러 서비스를 사용해야 하므로 비용이 많이들고, 아키텍처가 다소 복잡하므로 개발 및 관리의 난이도가 어렵고 비용이 많이든다.
- 토큰 : 서버에서 토큰을 발행해서 클라이언트의 화면에 hidden으로 넣는 것
- 클라이언트의 요청 시 해당 값으로 요청을 처리할 지 거부할 지 판단한다.
- 공격자는 이 값을 알 수 없기에 방어가 가능하게 된다.
- 쿠기 사용 안함 : 공격자가 이용할 세션 쿠키를 애초에 생성하지 않는 것
- 쿠키는 항상 서버로 전송된다는 특징이 있음
- 이를 이용해 사용자의 세션 쿠키를 통해 공격을 하는 것
- JWT와 REST API를 이용해서 CSR방식으로 구현한 애플리케이션에서 활용 가능
- referrer : 공격자의 사이트와 공격 대상 서버의 주소가 다를 것이므로 서버의 주소와 다른 주소에서의 호출을 모두 차단하는 형태,
- 따라서, 세션 로그인을 하는 경우 spring security에서 csrf 설정을 disable하면 안된다 !
- 하지만, React나 Vue같은 CSR방식을 채택한다면 보안정보를 담은 JWT를 주고 받으면 되기에 csrf를 꺼도 무방하다( 굳이 킬 필요가 없으니 끄는게 맞다)
- JWT를 사용하게 된다면 서버는 클라이언트의 상태를 완전히 저장하지 않는 Stateless를 유지할 수 있게 된다
코드 상세
http.csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and().formLogin().disable()
.httpBasic().disable()
- 앞서 말했듯이, csrf는 필요없으니 끄고
- 켜두면 csrf방어를 위한 토큰을 클라이언트에게 심게됨. 즉, 두번째 방어 기술을 사용함
- 따라서, 만약 클라이언트가 서버로 토큰을 전송하지 않으면 403
- 세션 정책을 STATELESS로 하면 세션을 생성하지 않고 서버를 stateless하게 유지함
- formLogin은 기본 시큐리티 로그인 화면을 의미하는데, 프론트 단에서 로그인 페이지를 구현할 것이니 꺼줌
- httpBasic은 default가 disable이긴 한데, 만약 enable하면 팝업 형식으로(alert형식?) 알림창으로 로그인하게 하는데, 이 알림으로 보내는 로그인 요청은 request 헤더에 id,pwd를 직접 날리는 것이라 보안에 매우 큰 위협이 된다
formLogin().loginPage("/auth/loginForm").loginProcessingUrl("/login").failureUrl("/auth/loginForm").permitAll().defaultSuccessUrl("/").successHandler((req, res, auth) -> {req.getSession().setAttribute("username",auth.getName());
res.sendRedirect("/");}).failureHandler(customLoginFailureHandler)
- 위처럼 세션을 사용하는 경우 자동으로 formlogin에서 processingurl을 지정해서(안해도 default가 login이긴 함) 로그인 기능을 쉽게 구현할 수 있음
- 하지만, JWT는 formlogin을 사용하지 않기에 직접 필터 클래스를 만들어서 붙여 줘야 함
- 기존 로그인 흐름에서 JWT 토큰을 발급하는 것을 추가해야 하기 때문
- 헤더에서 언급한 알고리즘에 따라 secret key 의 길이가 달라짐 ( json webtocken 알고리즘 검색하면 나올듯)
- HS512기준 64byte이상
- Base64로 문자열 인코딩때리는게 일반적인듯
사전 작업
jwt:
header: Authorization
#HS512 알고리즘을 사용할 것이기 때문에 512bit, 즉 64byte 이상의 secret key를 사용해야 한다.
#echo 'silvernine-tech-spring-boot-jwt-tutorial-secret-silvernine-tech-spring-boot-jwt-tutorial-secret'|base64
secret: c2lsdmVybmluZS10ZWNoLXNwcmluZy1ib290LWp3dC10dXRvcmlhbC1zZWNyZXQtc2lsdmVybmluZS10ZWNoLXNwcmluZy1ib290LWp3dC10dXRvcmlhbC1zZWNyZXQK
token-validity-in-seconds: 86400
implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.5'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.5'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.5'
corsConfig
@Bean
public CorsConfigurationSource corsConfigurationSource(){
CorsConfiguration source = new CorsConfiguration();
source.setAllowCredentials(true);
source.addAllowedOrigin("*");
source.setAllowedMethods(Arrays.asList("GET","POST"));
source.addAllowedHeader("*");
UrlBasedCorsConfigurationSource url= new UrlBasedCorsConfigurationSource();
url.registerCorsConfiguration("/**", source);
return url;
}
- 원래하던 것 처럼 CorsConfigurationSource를 bean으로 등록해도 되고
@Configuration
public class CorsConfig {
@Bean
public CorsFilter corsFilter() {
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
CorsConfiguration config = new CorsConfiguration();
config.setAllowCredentials(true);
config.addAllowedOrigin("*");
config.addAllowedHeader("*");
config.addAllowedMethod("*");
source.registerCorsConfiguration("/api/**", config);
return new CorsFilter(source);
}
}
------------------------
in security config
private final CorsFilter corsFilter;
생성자에 등록
.addFilterBefore(corsFilter, UsernamePasswordAuthenticationFilter.class)
- 이런식으로 필터 형태로 등록해서 사용해도 됨
AuthenticationEntryPoint, accessDeniedHandler
.authenticationEntryPoint((req, rsp, e) -> { System.out.println("authenticationEntryPoint Error:"+req.getServletPath());
if(req.getServletPath().equals("/error"))
rsp.sendRedirect("/auth/loginForm");
else rsp.sendRedirect("/?error=authenticationError");
})
.accessDeniedHandler((req, rsp, e) -> { System.out.println("accessDenied Error"); rsp.sendRedirect("/?error=accessDenied");})
- 이렇게 config파일에서 바로 간편하게 등록해도 되지만
in security config
마찬가지로 생성자로 DI
.authenticationEntryPoint(jwtAuthenticationEntryPoint)
.accessDeniedHandler(jwtAccessDeniedHandler)
--------------------
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException) throws IOException {
// 유효한 자격증명을 제공하지 않고 접근하려 할때 401
response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
}
}
--------------------
@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException {
//필요한 권한이 없이 접근하려 할때 403
response.sendError(HttpServletResponse.SC_FORBIDDEN);
}
}
- CSR방식에선 간편하게 라우팅을 하기 힘드니 이런식으로 따로 파일 빼서 작성하는 것이 좋다. (가독성 면에서도 더 좋을 듯)
- HttpServletResponse에 기본적으로 제공되는 여러 오류코드들이 존재함
FrameOption
.headers().frameOptions().sameOrigin()
- X-Frame-Option을 설정하는 부분, default는 deny
- iframe : HTML inline frame
- 제한 없이 다른 html페이지를 현재 페이지에 포함시키는 태그?다
<iframe src="page.html" width="300" height="300">
iframe을 지원하지 않는 브라우저입니다.
</iframe>
- mem h2가 iframe을 사용하기에 frameOption을 설정하지 않으면 오류가 발생, 어차피 origin은 같으니 sameorigin설정으로 이를 해결할 수 있음
JWT적용
// in SecurityConfig
// tokenProvider를 DI받아서 이를 JwtsecurityConfig의 생성자로 DI해주고
// 이를 security에 적용함
.apply(new JwtSecurityConfig(tokenProvider));
----------------------------------------------------------------------
//in JwtSecurityConfig
//JwtSecurityConfig는 전달받은 tokenProvider를 JwtFilter에 DI하여 만들어진 필터를
// usernamepasswordFilter전에 추가한다
public class JwtSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
private TokenProvider tokenProvider;
public JwtSecurityConfig(TokenProvider tokenProvider) {
this.tokenProvider = tokenProvider;
}
@Override
public void configure(HttpSecurity http) {
http.addFilterBefore(
new JwtFilter(tokenProvider),
UsernamePasswordAuthenticationFilter.class
);
}
}
-----------------------------------------------------------------------
//in JwtFilter
public class JwtFilter extends GenericFilterBean {
private static final Logger logger = LoggerFactory.getLogger(JwtFilter.class);
public static final String AUTHORIZATION_HEADER = "Authorization";
private TokenProvider tokenProvider;
//1.JwtFilter는 전달받은 tokenprovider를 이용해서,
public JwtFilter(TokenProvider tokenProvider) {
this.tokenProvider = tokenProvider;
}
//do Filter는 토큰의 인증정보를 현재 실행중인 security context에 저장하는 역할을 한다
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest;
//5.해당 토큰이 존재한다면 Bearer부분을 제외한 나머지 부분을 없다면 null 값을 jwt에 저장한다
String jwt = resolveToken(httpServletRequest);
String requestURI = httpServletRequest.getRequestURI();
//6.그 후 jwt값을 validate하고 유효 하다면 해당 토큰으로부터 인증정보를 얻고
//SecurityContextHolder에 인증정보를 추가한다
//유효하지 않으면 log만 하나 띄움
if (StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)) {
Authentication authentication = tokenProvider.getAuthentication(jwt);
SecurityContextHolder.getContext().setAuthentication(authentication);
logger.debug("Security Context에 '{}' 인증 정보를 저장했습니다, uri: {}", authentication.getName(), requestURI);
} else {
logger.debug("유효한 JWT 토큰이 없습니다, uri: {}", requestURI);
}
//7.이렇게 전달받은 tokenprovider를 통해 토큰의 유효성을 검사하고
//인증정보를 저장하는 역할을 하는 필터임
// (filterChain(req,res)로 얘를 필터로서 활용할 수 있게끔 등록)
filterChain.doFilter(servletRequest, servletResponse);
}
//2.HttpServletRequest에서 AuthorizationHeader를 얻어오고
private String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
//3.해당 헤더에 Bearer토큰이 존재하는 지 확인한다
//4.Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJhZG1pbiIsImF1dGgiOiJST0xFX0FETUlOLFJPTEVfVVNFUiIsImV4cCI6MTY3ODMzNzQ1N30.qAODj1iTA7H8kyAe_dc8PRaS1shlX9ma75MQDKE1EqzZ6_e9dClP9XqiuFmmgmcVGqrQn-HpopagQ7o50M1yoQ
//이런식으로 bearer토큰이 있으면 Bearer로 시작 함
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
}
----------------------------------------------------------------------
//in TokenProvider
@Component
public class TokenProvider implements InitializingBean {
private final Logger logger = LoggerFactory.getLogger(TokenProvider.class);
private static final String AUTHORITIES_KEY = "auth";
private final String secret;
private final long tokenValidityInMilliseconds;
private Key key;
//1.처음 생성될 떄 application.yml에 저장한 secret, token-validity-in-seconds를 생성될 때 저장한다.
public TokenProvider(
@Value("${jwt.secret}") String secret,
@Value("${jwt.token-validity-in-seconds}") long tokenValidityInSeconds) {
this.secret = secret;
this.tokenValidityInMilliseconds = tokenValidityInSeconds * 1000;
}
//2. InitializingBean을 구현한 클래스이므로,
//DI들이 다 끝나면 afterPropertiesSet이 호출되는데, 여기서 DI받은 secret을 해독하고
//hmac SHA를 이용해 이를 키로 만든다.
@Override
public void afterPropertiesSet() {
byte[] keyBytes = Decoders.BASE64.decode(secret);
this.key = Keys.hmacShaKeyFor(keyBytes);
}
//인증 정보를 받아 토큰을 생성하는 메소드
public String createToken(Authentication authentication) {
//로그인 정보를 받아서 authentication의 authority를 리턴하는 GrantedAuthority의 getAuthority를 mapping
//mapping한 정보를 Collector를 이용해서 ","로 이어주며 authorities에 저장
String authorities = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));
//현재 시간을 받아 토큰의 유효 기간을 설정
long now = (new Date()).getTime();
Date validity = new Date(now + this.tokenValidityInMilliseconds);
//Jwts(JWT를 위한 factory class) 빌더를 이용해 토큰 리턴
//subject: claim을 주장할 대상( 사용자 이름 )
//claim : 하단 설명참고, 간단하게 신상정보라고 생각해도 될 듯
//이 신상 정보를 signWith()에서 서명
//Jwt의 만료를 setExpiration으로 설정
//compact()호출로 JWT Compact Serialization규칙에 따라
//실제로 JWT를 build !!자세히는 좀 ..!!
return Jwts.builder()
.setSubject(authentication.getName())
.claim(AUTHORITIES_KEY, authorities)
.signWith(key, SignatureAlgorithm.HS512)
.setExpiration(validity)
.compact();
}
//토큰을 받아 인증 정보를 리턴하는 메소드(JWT filter만들 떄 인증정보 얻기 위해 만든 메소드)
public Authentication getAuthentication(String token) {
//전달 받은 JWT token을 claim으로 decode한다
Claims claims = Jwts
.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody();
//claim에서 AUTHORITIES_KEY로 매핑한 authorities를 ","로 파싱해서 다시 GrantedAuthority로 변환한다
Collection<? extends GrantedAuthority> authorities =
Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(","))
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
//저장한 subject의 이름과 얻은 권한으로 유저 객체를 만든다
User principal = new User(claims.getSubject(), "", authorities);
//여기서 Authentication을 리턴하는 메소드가 왜 UsernamePasswordAuthenticationToken을 리턴하냐?
//얘는 AbstractAuthenticationToken을 extends하고 쟤는 Authentication,CredentialContainer를 implement함
//즉, 얘가 securityContext에 등록되는 Authentication객체의 역할을 한다는 것
return new UsernamePasswordAuthenticationToken(principal, token, authorities);
}
//토큰을 받아 유효성 검사를 하는 메소드
public boolean validateToken(String token) {
//가지고 있는 key를 통해 토큰의 JWS(JWT의 signature)를 파싱해서 리턴한다.
//Jws<Claims>형태로 리턴, signature와 body(claim(sub, 저장한claim, exp ..))를 조회 가능
//이 과정에서 여러 오류가 throw되는데, 아래 catch문 처럼 처리한다.
//즉, 무사히 파싱하고 정상적으로 리턴한다면 해당 토큰은 현재 서버가 서명한 것이 맞다는 뜻이다
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
return true;
} catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
logger.info("잘못된 JWT 서명입니다.");
} catch (ExpiredJwtException e) {
logger.info("만료된 JWT 토큰입니다.");
} catch (UnsupportedJwtException e) {
logger.info("지원되지 않는 JWT 토큰입니다.");
} catch (IllegalArgumentException e) {
logger.info("JWT 토큰이 잘못되었습니다.");
}
return false;
}
}
----------------------------------------------------------------------
@PostMapping("/authenticate")
public ResponseEntity<TokenDto> authorize(@Valid @RequestBody LoginDto loginDto) {
//입력받은 정보를 바탕으로 authenticationToken을 생성한다
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(loginDto.getUsername(), loginDto.getPassword());
//해당 토큰을 authenticationManger가 authenticate하고 build된 object를 꺼내 authentication객체로 저장
//즉 이 부분에서 authenticate, 로그인 시도를 하는 것
//DisabledException, LockedException, BadCredentialException이 발생 가능
Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
//이 인증 정보를 바탕으로 JWT를 생성
String jwt = tokenProvider.createToken(authentication);
HttpHeaders httpHeaders = new HttpHeaders();
httpHeaders.add(JwtFilter.AUTHORIZATION_HEADER, "Bearer " + jwt);
//JWT를 body와 httpHeader의 Authorization에 붙여 전달
return new ResponseEntity<>(new TokenDto(jwt), httpHeaders, HttpStatus.OK);
}
Bearer 토큰
Bearer 토큰은 토큰을 소유한 사람에게 액세스 권한을 부여하는 일반적인 토큰 클래스.
액세스 토큰, ID 토큰, 자체 서명 JWT는 모두 Bearer 토큰이다.
인증에 Bearer 토큰을 사용하려면 HTTPS와 같은 암호화된 프로토콜로 제공되는 보안이 필요하다.
Bearer 토큰을 가로채면 악의적인 행위자가 이를 사용하여 액세스 권한을 얻을 수 있다.
Bearer 토큰이 사용 사례에 충분한 보안을 제공하지 않으면 또 다른 암호화 레이어를 추가하거나 BeyondCorp Enterprise와 같이 신뢰할 수 있는 기기의 인증된 사용자만으로 액세스를 제한하는 상호 전송 계층 보안(mTLS)을 사용하는 것이 좋다.
InitializationBean, DisposableBean
- initializationBean : DI가 끝나면 호출된다.
- 즉, 생성자들 다 만들고 나면 실행된다.
- DisposableBean : 스프링 컨테이너 종료 호출 시 호출된다.
Claim
클레임(Claim)은 주체가 수행할 수 있는 작업보다는 주체가 무엇인지를 표현하는 이름과 값의 쌍이라고 한다.
예를들어 운전 면허증을 가지고 있다고 가정하면,
생년월일(1999년 1월 21일)이 적혀있는 클레임의 이름은 DateOfBirth라고 할 수 있고, 클레임의 값은 19990121이며 발급자는 운전면허 발급기관이 된다.
클레임 기반 권한부여는 클레임 값을 검사한 후에 이 값을 기반으로 리소스에 대한 접근을 허용한다.
그리고 운전면허증을 통해 인증을 거쳐하는 경우가 생긴다면 권한 부여 절차가 진행된다.
인증을 요구하는 경우가 생기면 접근을 허용하기 전에 먼저 클레임의 값(DateOfBitrth)와 발급기관을 신뢰할 수 있는지 여부부터 확인한다.
참고자료
'Spring' 카테고리의 다른 글
[Spring Boot] SMTP를 이용해 이메일 인증을 구현해보자 (0) | 2023.04.17 |
---|---|
Spring Batch (0) | 2023.04.05 |
ExceptionHandling (0) | 2023.04.05 |
Spring Security - Cors. setAllowCredentials (0) | 2023.04.05 |
What is JWT?? (2) (With OAuth2.0) (0) | 2023.04.05 |