
서버는 SessionID를 가지고 와야만 Client가 이전에 방문했다는 사실을 안다.
하지만 SessionID가 위변조될 수 있기 때문에 Server는 Session의 목록을 유지해야 한다.
(-) 동시접속자 수가 많아져 로드 밸런싱을 통해 서버를 분산시키면 모든 서버에 저장된 Session Table를 검사하여야 한다
=> Sticky Session, DB 공유 등이 가능하지만 여전히 단점이 존재한다

OSI 7 Layer 상의 4계층(Transport Layer)에 속하는 신뢰적 전송 프로토콜
1. 하나의 클라이언트가 Synchronization 메세지를 전송한다
2. 메세지를 받은 서버가 Acknowledgement 메세지와 SYN 메세지를 전송한다
3. 최종적으로 서버와 클라이언트 모두 ACK를 받으면 연결이 완료된다

특정 정보에 대해서 허가된 사용자 또는 대상에 대해서만 확인이 가능해야 한다.
허가바지 않은 사용자 혹은 대상이 정보에 접근이 가능하다면 기밀성이 깨지게 된다.
특정 정보에 대해서 허가된 사용자 또는 대상에 대해서만 수정 및 삭제가 가능해야한다.
허가받지 않은 사용자 혹은 대상이 특정 정보에 대해서 수정 및 삭제가 되어야 하지 않아야 하며, 만약 수정 및 삭제가 이루어진다면 해당 정보에 대해서는 무결성이 깨지게 된다.
사용자 또는 대상에 대해서 특정 정보에 대한 접근 및 사용이 필요할 때 항상 이러한 접근이 가능해야 한다.
원하는 시간, 환경, 서비스에서 특정 정보를 항상 사용할 수 있어야 하며, 만약 사용이 불가하다면 해당 정보에 대해서는 가용성이 깨지게 된다.
CIA를 모두 지키면서 데이터를 전송할 때 두 가지의 문제가 발생한다
1. Key를 어떻게 전달할 것인지
2. 해당 데이터나 Key가 누구로부터 왔는지 판별하는 것 (인증 문제)

Public Key Sender의 데이터를 암호화한다. 이때 Public Key는 Recipient의 공개키이다.
Private Key Public Key는 같은 사용자의 Private Key로만 Decrypt할 수 있다.
따라서 Recipient는 Sender에게 Public Key를 공개하고 Encrypted된 데이터를 받았을때
Recipient의 Private Key를 통해 Decrypt하여 데이터에 접근할 수 있다.
만약 Encrypted된 데이터와 Public Key가 중간에 탈취되더라고
이를 탈취한 악성 사용자는 Recipient의 Private Key를 가지고 있지 않기 때문에 데이터에 접근할 수 없다.
RFC(Request for Comments) 문서란 "의견을 요청하는 문서"라는 의미로, 국제 인터넷 표준화 기구 (IETF)에서 관리하는 기술 표준
콘텐츠에 대해 특별한 제한은 없지만 주로 프로토콜(protocol) 및 파일 형식등이 주요 주제이며 승인된 문서는 유일한 일련 번호를 갖게 되며 "RFC-일련번호" 형식으로 불린다.
대표적으로 웹의 기반이 되는 프로토콜인 HTTP 1.1 은 RFC-2068 에서 시작됐으며 이를 개선한 RFC-2616 도 발표되었고 메일을 전송하는 프로토콜인 SMTP(Simple Mail Transfer Protocol) 는 RFC-821 에서 시작됐다.
정식으로 일련 번호가 부여된 RFC 문서는 절대로 변경되거나 폐지되지 않는다.
만약 변화하는 환경에 맞게 프로토콜이 업그레이드되어 기술 표준을 갱신할 경우 RFC는 명확성을 위해 기존 문서를 갱신하는게 아니라 새로운 RFC를 만들고 이로 인해 어떤 문서가 무효화되었는지 표시한다.
JWT(Json Web Token)은 Json 객체에 인증에 필요한 정보들을 담은 후 비밀키로 서명한 토큰으로, 인터넷 표준 인증 방식이다. 공식적으로 인증(Authentication) & 권한허가(Authorization) 방식으로 사용된다.
alg : Signature에서 사용하는 알고리즘
typ : 토큰 타입
Signature에서 사용하는 알고리즘은 대표적으로 RS256(공개키/개인키)와 HS256(비밀키(대칭키))가 있다. 이 부분은 auth0 공식 문서에서 자세히 설명해주고 있다.
사용자 정보의 한 조각인 클레임(claim)이 들어있다.
sub : 토큰 제목 (subject)
aud : 토큰 대상자 (audience)
iat : 토큰이 발급된 시각 (issued at)
exp : 토큰의 만료 시각 (expired)
Signature는 헤더와 페이로드의 문자열을 합친 후에, 헤더에서 선언한 알고리즘과 key를 이용해 암호한 값이다.
Header와 Payload는 단순히 Base64url로 인코딩되어 있어 누구나 쉽게 복호화할 수 있지만, Signature는 key가 없으면 복호화할 수 없다.
이를 통해 보안상 안전하다는 특성을 가질 수 있게 되었다.
앞서 언급한 것처럼 header에서 선언한 알고리즘에 따라 key는 개인키가 될 수도 있고 비밀키가 될 수도 있다. 개인키로 서명했다면 공개키로 유효성 검사를 할 수 있고, 비밀키로 서명했다면 비밀키를 가지고 있는 사람만이 암호화 복호화, 유효성 검사를 할 수 있다.
이때 header와 payload는 이렇게 누구나 쉽게 확인할 수 있기 때문에 이 부분들에는 중요한 정보를 담으면 안된다.
// /controller/RestApiController.java
@RestController
public class RestApiController {
// 모든 사람이 접근 가능
@GetMapping("home")
public String home() {
return "<h1>home</h1>";
}
}
위와같이 RestApiController에 /home 라우트를 생성한다.
// /model/User.java
@Entity
@Data
public class User {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private long id;
private String username;
private String password;
private String roles;
// ENUM으로 설정하지 않고 ,로 해서 구분해서 ROLE을 입력
// ,로 구분된 ROLE을 Parsing하는 Getter
public List<String> getRoleList(){
if(this.roles.length() > 0){
return Arrays.asList(this.roles.split(","));
}
return new ArrayList<>();
}
}
이후 프로젝트에서 사용할 User 모델을 생성한다
@Configuration
public class CorsConfig {
@Bean
public CorsFilter corsFilter() {
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
CorsConfiguration config = new CorsConfiguration();
// 내 서버가 응답을 할 때 JSON을 JS에서 처리할 수 있을지 여부 설정
// false로 설정하면 JS로 요청했을 때 응답하지 않는다
config.setAllowCredentials(true);
// 모든 Origin에 대한 응답 허용
config.addAllowedOrigin("*");
// 모든 Header에 대한 응답 허용
config.addAllowedHeader("*");
// 모든 Method에 대한 응답 허용
config.addAllowedMethod("*");
// /api 로 들어오는 모든 요청은 위 Config를 따른다는 의미
source.registerCorsConfiguration("/api/**", config);
return new CorsFilter(source);
}
}
위와 같이 Cross Origin을 허용해줄 수 있도록 CorsConfig 클래스를 생성한다.
@Configuration
// 시큐리티 활성화, 기본 스프링 필터체인에 등록
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter{
@Autowired
private CorsConfig corsConfig;
@Override
protected void configure(HttpSecurity http) throws Exception {
http
// 모든 요청은 무조건 corsConfig.corsFilter() 필터를 통과하게 된다
// 또한 Security Filter에서 등록 인증을 수행한다
.addFilter(corsConfig.corsFilter())
.csrf().disable()
// STATELESS 방식 : Session을 사용하지 않겠다는 의미
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.formLogin().disable()
// Bearer 방식을 사용하기 위해 Basic 방식 Disable
.httpBasic().disable()
.authorizeRequests()
.antMatchers("/api/v1/user/**")
.access("hasRole('ROLE_USER') or hasRole('ROLE_MANAGER') or hasRole('ROLE_ADMIN')")
.antMatchers("/api/v1/manager/**")
.access("hasRole('ROLE_MANAGER') or hasRole('ROLE_ADMIN')")
.antMatchers("/api/v1/admin/**")
.access("hasRole('ROLE_ADMIN')")
.anyRequest().permitAll();
}
}
위와 같이 SecurityConfig를 정의한다.
이때, CorsConfig에서 정의한 Filter를 필터링 해주어 CORS를 허용한다.
@RestController
@CrossOrigin
public class RestApiController {
...
또한 위와 같이 @CrossOrigin Annotation으로 Controller에 직접적으로 CORS를 허용할 수 있는데, 해당 방법은 인증이 필요한 요청에 대해서는 모두 거부되어 이상적인 방법은 아니다.
HTTP Only : JS로 쿠키를 담아 요청하면 거부
HTTP Basic : Header에 Authorization을 담아서 요청
HTTP Basic의 경우에는 Header가 노출되기 때문에 HTTPS를 사용해야 한다.
HTTP Bearer : 노출이 되어도 위험 부담이 적은 Authorization Token을 Header에 담아서 요청
Security Filter Chain 이란 Spring Security 에서 제공하는 인증, 인가를 위한 필터들의 모음이다.
Spring Security 에서 가장 핵심이 되는 기능을 제공하며
거의 대부분의 서비스는 Security Filter Chain 에서 실행된다.
public class MyFilter1 implements Filter{
@Override
public void doFilter(Servlet request, Servlet response, FilterChain chain) throws IOException, ServletException{
System.out.println("MyFilter1");
chain.doFilter(request, response);
}
}
public class MyFilter2 implements Filter{
@Override
public void doFilter(Servlet request, Servlet response, FilterChain chain) throws IOException, ServletException{
System.out.println("MyFilter2");
chain.doFilter(request, response);
}
}
새로운 Filter인 MyFilter1, MyFilter2를 위와 같이 정의한다
http.
.addFilter(new MyFilter1()))
해당 필터는 위와 같이 Spring Security에 추가할 수 있다.
또한 Security Filter에 등록할 때 addFilterBefore, addFilterAfter를 사용할 수 있다.
@Configuration
public class FilterConfig{
@Bean
public FilterRegistrationBean<MyFilter1> filter1{
FilterRegistrationBean<MyFilter1> bean = new FilterRegistrationBean<>(new MyFilter1());
bean.addUrlPatterns("/*");
// 낮은 번호가 필터 중에서 가장 먼저 실행됨
bean.setOrder(0);
return bean;
}
@Bean
public FilterRegistrationBean<MyFilter2> filter2{
FilterRegistrationBean<MyFilter2> bean = new FilterRegistrationBean<>(new MyFilter2());
bean.addUrlPatterns("/*");
bean.setOrder(1);
return bean;
}
}
추가로 위와 같이 FilterConfig를 정의하면, 각 Method들이 IoC되어 있기 때문에 페이지에 요청을 보내면 각 Filter들이 실행된다.
또한 Spring Security Filter들은 FilterConfig에서 정의된 Filter들보다 우선적으로 동작한다.
// /auth/PrincipalDetails.java
public class PrincipalDetails implements UserDetails{
private User user;
public PrincipalDetails(User user){
this.user = user;
}
public User getUser() {
return user;
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getUsername();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> authorities = new ArrayList<GrantedAuthority>();
user.getRoleList().forEach(r -> {
authorities.add(()->{ return r;});
});
return authorities;
}
}
로그인을 진행하기 위해 위와 같이 PrincipalDetails를 정의하고
// /auth/PrincipalDetails.java
@Service
@RequiredArgsConstructor
public class PrincipalDetailsService implements UserDetailsService{
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
System.out.println("PrincipalDetailsService " + username);
User user = userRepository.findByUsername(username);
// session.setAttribute("loginUser", user);
return new PrincipalDetails(user);
}
}
PrincipalDetailsService 클래스를 위와 같이 정의한다.
이때 loadUserByUsername에서는 user를 userRepository로부터 찾아 새로운 PrincipalDetails를 만들어 반환한다.
현재 Security Config에서 .formLogin().disable()를 통해 formLogin을 disable 해두었기 때문에 기존의 로그인 방식은 동작하지 않는다.
// /dto/LoginRequestDto.java
@Data
public class LoginRequestDto {
private String username;
private String password;
}
따라서 LoginRequestDto와 Filter를 생성하여 로그인을 진행한다.
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter{
private final AuthenticationManager authenticationManager;
// Authentication 객체 만들어서 리턴 => 의존 : AuthenticationManager
// 인증 요청시에 실행되는 함수 => /login
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException {
System.out.println("JwtAuthenticationFilter : 진입");
// request에 있는 username과 password를 파싱해서 자바 Object로 받기
ObjectMapper om = new ObjectMapper();
LoginRequestDto loginRequestDto = null;
try {
loginRequestDto = om.readValue(request.getInputStream(), LoginRequestDto.class);
} catch (Exception e) {
e.printStackTrace();
}
System.out.println("JwtAuthenticationFilter : "+loginRequestDto);
// 유저네임패스워드 토큰 생성
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(
loginRequestDto.getUsername(),
loginRequestDto.getPassword());
System.out.println("JwtAuthenticationFilter : 토큰생성완료");
// authenticate() 함수가 호출 되면 인증 프로바이더가 유저 디테일 서비스의
// loadUserByUsername(토큰의 첫번째 파라메터) 를 호출하고
// UserDetails를 리턴받아서 토큰의 두번째 파라메터(credential)과
// UserDetails(DB값)의 getPassword()함수로 비교해서 동일하면
// Authentication 객체를 만들어서 필터체인으로 리턴해준다.
// Tip: 인증 프로바이더의 디폴트 서비스는 UserDetailsService 타입
// Tip: 인증 프로바이더의 디폴트 암호화 방식은 BCryptPasswordEncoder
// 결론은 인증 프로바이더에게 알려줄 필요가 없음.
Authentication authentication =
authenticationManager.authenticate(authenticationToken);
PrincipalDetails principalDetailis = (PrincipalDetails) authentication.getPrincipal();
System.out.println("Authentication : "+principalDetailis.getUser().getUsername());
return authentication;
}
}
UsernamePasswordAuthenticationFilter는 Spring Security에 기본적으로 포함이 되어있는 Filter로 Username, Password를 POST 방식으로 전송하면 동작한다.
하지만 현재 formLogin()이 disabled 되어 있어 Security Config에 새로운 필터를 등록해줘야 한다.
http
...
.addFilter(new JwtAuthenticationFilter(authenticationManager()))
이때 UsernamePasswordAuthenticationFilter는 WebSecurityConfigurerAdapter에 포함된 authenticationManager를 통해 Auth를 진행하기 때문에 이를 파라미터로 넘겨주어야 한다.
package com.cos.jwtex01.config.jwt;
public interface JwtProperties {
String SECRET = "JWT-SECRET";
int EXPIRATION_TIME = 864000000;
String TOKEN_PREFIX = "Bearer ";
String HEADER_STRING = "Authorization";
}
이후 위와 같이 JWT Token 생성을 위해 JwtProperties를 정의한다.
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter{
...
// JWT Token 생성해서 response에 담아주기
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
Authentication authResult) throws IOException, ServletException {
PrincipalDetails principalDetailis = (PrincipalDetails) authResult.getPrincipal();
String jwtToken = JWT.create()
.withSubject(principalDetailis.getUsername())
.withExpiresAt(new Date(System.currentTimeMillis()+JwtProperties.EXPIRATION_TIME))
.withClaim("id", principalDetailis.getUser().getId())
.withClaim("username", principalDetailis.getUser().getUsername())
.sign(Algorithm.HMAC512(JwtProperties.SECRET));
response.addHeader(JwtProperties.HEADER_STRING, JwtProperties.TOKEN_PREFIX+jwtToken);
}
}
JwtAuthenticationFilter에서 attemptAuthentication 실행 후 인증이 완료되면 successfulAuthentication 메소드가 실행된다.
따라서 인증이 완료되었을 때 JwtProperties의 정보를 활용하여 JWT Token을 생성하고 response의 헤더에 추가하여준다.
// /config/jwt/JwtAuthorizationFilter.java
// Authorization : 인가
public class JwtAuthorizationFilter extends BasicAuthenticationFilter{
private UserRepository userRepository;
public JwtAuthorizationFilter(AuthenticationManager authenticationManager, UserRepository userRepository) {
super(authenticationManager);
this.userRepository = userRepository;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws IOException, ServletException {
String header = request.getHeader(JwtProperties.HEADER_STRING);
if(header == null || !header.startsWith(JwtProperties.TOKEN_PREFIX)) {
chain.doFilter(request, response);
return;
}
System.out.println("header : "+header);
String token = request.getHeader(JwtProperties.HEADER_STRING)
.replace(JwtProperties.TOKEN_PREFIX, "");
// 토큰 검증 (이게 인증이기 때문에 AuthenticationManager도 필요 없음)
// 내가 SecurityContext에 집적접근해서 세션을 만들때 자동으로 UserDetailsService에 있는 loadByUsername이 호출됨.
String username = JWT.require(Algorithm.HMAC512(JwtProperties.SECRET)).build().verify(token)
.getClaim("username").asString();
if(username != null) {
User user = userRepository.findByUsername(username);
// 인증은 토큰 검증시 끝. 인증을 하기 위해서가 아닌 스프링 시큐리티가 수행해주는 권한 처리를 위해
// 아래와 같이 토큰을 만들어서 Authentication 객체를 강제로 만들고 그걸 세션에 저장!
PrincipalDetails principalDetails = new PrincipalDetails(user);
Authentication authentication =
new UsernamePasswordAuthenticationToken(
principalDetails, //나중에 컨트롤러에서 DI해서 쓸 때 사용하기 편함.
null, // 패스워드는 모르니까 null 처리, 어차피 지금 인증하는게 아니니까!!
principalDetails.getAuthorities());
// 강제로 시큐리티의 세션에 접근하여 값 저장
SecurityContextHolder.getContext().setAuthentication(authentication);
}
chain.doFilter(request, response);
}
}
BasicAuthenticationFilter는 Security의 필터들 중 권한이나 인증이 필요한 특정 주소를 요청했을 때 무조건 거치게 되는 Filter이며
만약 권한이나 인증이 필요하지 않다면 해당 Filter를 거치지 않는다.
http
.addFilter(new JwtAuthorizationFilter(authenticationManager(), userRepository))
또한 Spring Cecurity에 JwtAuthorizationFilter를 등록해주면 최종적으로 JWT Token 서버 구현이 완료된다.
Reference
최주호 - 스프링부트 시큐리티 & JWT
https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81%EB%B6%80%ED%8A%B8-%EC%8B%9C%ED%81%90%EB%A6%AC%ED%8B%B0/dashboard