GDG on Campus SKHU의 Server 파트를 위한 강의자료 입니다.
지난 시간 우리는 Session로그인에 대해서 배웠어요
이번시간에는 JWT에 대해서 배워볼 거에요
JWT는 Json Web Token의 약자로 전사 서명된 URL-safe(URL로 이용할 수 있는 문자로 구성된)의 JSON으로 유저를 인증하고 식별하기 위한 Token 기반 인증이에요
JWT는 JSON 데이터를 Base64 URL-safe Encode를 인코딩하여 직렬화한 것이 포함되어요
따라서 클라이언트가 JWT를 서버로 전송하면 서버는 JWT를 검증하는 과정을 거치게 되며, 검증이 완료되면 요청한 응답을 돌려줘요
Base64 URL-safe Encode는 일반적인 Base64 Encode를 URL에서 오류없이 사용하도록 표현합니다.
보편적으로 JWT
를 사용하는 경우의 인증 흐름을 알아볼게요
JWT
와 Session
과 같이 인증(Authentication)은 왜 필요한걸까?
토큰의 타입과 해시 암호화 알고리즘에 대한 정보를 담고 있어요
{
"alg": "HS256",
"typ": "JWT"
}
토큰에 담을 정보가 들어있습니다. 여기에 담은 하나의 정보를 클레임이라고 해요
클레임은 등록된 클레임, 공개 클레임, 비공개 클레임이 존재해요 이런게 있구나 하고 넘어갈게요
{
"sub":"12345", // 등록된 플레임
"name": "woogym",
"iat" : 17889271
}
클레임이란?
payload에 담긴 정보의 한 조각을 클레임이라고 해요, name/value의 한 쌍으로 이루어져 있어요
서명은 [헤더 base64 + 페이로드 base64 + SECRET_KEY]를 사용해서 JWT 백엔드에서 발행해서 클라이언트에게 제공해요, 만약 헤더, 페이로드의 정보가 클라이언트에 의해 변경된 경우 서명이 무효화됩니다
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
your-256-bit-secret
)
Ps.서버에서 가장 기피해야할 것은 DB조회에요
이와 관련해서 JWT는 별도의 DB조회를 필요로 하지 않는 장점을 가지고 있어요
그렇다면 어디에? 클라이언트의Cookie
,LocalStorage
에 저장해요 이런게 있구나 하고 넘어가도록해요
Session vs JWT
이세상에 100% 완벽한 보안을 유지하는 소프트웨어, 하드웨어는 존재하지 않아요 그런만큼 session과 jwt 둘 다 보안에 취약한 부분이 있어요
1) XSS - SQL Injection : 토큰이나 세션의 민감한 정보를 탈취
2) CSRF - 세션 라이딩 기법 : 공격자가 의도한 행위를 특정 웹사이트에 요청하게 하는 공격 기법 (사용자는 자신도 모르게 공격을 수행하는 사람이 되어버려요~)
세션 로그인 시간에 배웠던 Spring Security를 배웠어요, 기본적으로 Spring Security는 세션 & 쿠키 방식의 인증을 제공해요, 그중에서 Spring Security의 filter chain
을 활용하여 JWT
를 사용한 인증, 인가를 구현할 수 있어요
filter chain
활용의 핵심인 SecurityContextHolder
의 개념에 대해서 알아볼게요
Spring Security의 핵심 구성 요소에요, 현재 실행중인 스레드에 대한 보안 컨텍스트 정보를 저장하고 추출할 수 있는 매커니즘을 제공해요. 보안 컨텍스트에는 현재 인증된 사용자에 대한 인증 객체 및 권한 정보등을 포함하고 있어요
Spring Security의 중요한 역할을 하는 인터페이스로, 애플리케이션의 보안 관련 정보를 포함하 현재 스레드의 인증상태와 권한 정보등을 저장해요
Spring Security에서 사용자에 대한 인증 정보를 나타내는 역할을 담당하는 인터페이스에요, Authentication
객체는 사용자의 신원 정보 및 인증 상태를 나타내며 권한 및 인가 처리에 필요한 기본 요소를 제공해요
Principal
: 사용자의 주체 정보를 나타내며, 일반적으로 사용자 이름, ID, 비밀번호, 이메일과 같은 정보들을 포함해요Credentials
: 주로 사용자의 비밀번호와 같은 인증 증거를 나타내요Authorities
: 현재 사용자에게 부여된 권한의 목록을 나타내요, GrantedAuthority
인터페이스 구현체를 사용해서 각 권한을 나타내요, 보편적으로 ROLE_USER
, ROLE_ADMIN
과 같이 문자열 형식으로 표현해요Authentication
의 구현체인 AbstractAuthenticationToken
의 하위 클래스에요
쉽게 생각해서 Spring Security에서 인증을 수행하기 위해 필요한 토큰이라고 생각하면 됩니다
인증 성공 후 인증에 성공한 사용자의 인증 정보가 UsernamePasswordAuthenticationToken
에 포함되어 Authentication
객체 형태로 SecurityContext
에 저장되는 동작을 담당해요
SpringSecurity는 러닝 커브가 굉장히 높아요 그만큼 방대하다는 뜻이에요, 실무에서 많이 쓰이지 않는 부분이니 사이드 프로젝트를 위한 수준까지만 딱 배우고 써먹어봅시다!
간단한 회원가입 예제를 통해서 실습해볼게요
JWT와 JSON 관련 의존성을 build.gradle
에 추가하고 적용해줘야해요! (🔄모양 표시를 누르면 된답니다)
dependencies {
...
implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.2'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.2'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.2'
...
}
spring:
datasource:
url: ${DB_JDBC_URL}
username: ${DB_USER}
password: ${DB_PASSWORD}
driver-class-name: com.mysql.cj.jdbc.Driver
jpa:
database: mysql
database-platform: org.hibernate.dialect.MySQL8Dialect
hibernate:
ddl-auto: create
show-sql: true
properties:
hibernate:
format_sql: false
use_sql_comments: true
jwt:
secret: ${JWT_SECRET}
access-token-validity-in-milliseconds: ${ACCESS_TOKEN_VALIDITY_IN_MILLISECONDS}
JWT_SECRET
은 JWT 서명과 검증에 필요한 원본 값이에요, HMAC 서명 알고리즘에서 JWT의 무결성을 보장하는데 사용되요 JWT_SECRET이 외부에 알려지지 않는 한, JWT가 수정되면 검증 시에 무효화되요JWT_SECRET 생성 방법
macOs : 터미널 접속 ->openssl rand -hex 32
명령어 입력
window : 윈도우는 git bash를 사용해서openssl rand -hex 32
명령어를 입력하셔야합니다. git bash에 자동으로 openssl이 내장되어 있습니다. 다른 방법도 있지만 복잡하고 시간이 오래 걸려서 git bash를 권장합니다.
access-token-validity-in-milliseconds
는 토큰 만료 기간을 설정해요1800000
= 30분
으로 설정할게요1번과 2번 둘 다 보안을 위해 민감한 정보는 환경변수로 저장할거에요
public enum Role {
ROLE_USER;
}
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "USER_EMAIL", nullable = false)
private String email;
@Column(name = "USER_PASSWORD", nullable = false)
private String password;
@Column(name = "USER_PHONE_NUMBER", nullable = false)
private String phoneNumber;
@Enumerated(EnumType.STRING)
@Column(name = "USER_ROLE", nullable = false)
private Role role;
@Builder
public User(String email, String password, String phoneNumber) {
this.email = email;
this.password = password;
this.phoneNumber = phoneNumber;
this.role = Role.USER;
}
}
@Getter
@Builder
@AllArgsConstructor
public class TokenDto {
private String accessToken;
}
@Getter
public class UserSignUpDto {
private String email;
private String password;
private String phoneNumber;
}
@Getter
@Builder
@AllArgsConstructor
public class UserInfoDto {
private String email;
private String phoneNumber;
private String role;
}
@Component
public class TokenProvider {
private static final String ROLE_CLAIM = "Role";
private static final String BEARER = "Bearer ";
private static final String AUTHORIZATION = "Authorization";
private final Key key;
private final long accessTokenValidityTime;
public TokenProvider(@Value("${jwt.secret}") String secretKey,
@Value("${jwt.access-token-validity-in-milliseconds}") long accessTokenValidityTime) {
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
this.key = Keys.hmacShaKeyFor(keyBytes);
this.accessTokenValidityTime = accessTokenValidityTime;
}
public String createAccessToken(User user) {
long nowTime = (new Date().getTime());
Date accessTokenExpiredTime = new Date(nowTime + accessTokenValidityTime);
return Jwts.builder()
.setSubject(user.getId().toString())
.claim(ROLE_CLAIM, user.getRole().name())
.setExpiration(accessTokenExpiredTime)
.signWith(key, SignatureAlgorithm.HS256)
.compact();
}
public Authentication getAuthentication(String accessToken) {
Claims claims = parseClaims(accessToken);
if (claims.get(ROLE_CLAIM) == null) {
throw new RuntimeException("권한 정보가 없는 토큰입니다.");
}
// 사용자의 권한 정보를 securityContextHolder에 담아준다
Collection<? extends GrantedAuthority> authorities =
Arrays.stream(claims.get(ROLE_CLAIM).toString().split(","))
// 해당 hasRole이 권한 정보를 식별하기 위한 전처리 작업
.map(role -> new SimpleGrantedAuthority("ROLE_" + role))
.collect(Collectors.toList());
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(claims.getSubject(), "", authorities);
authentication.setDetails(claims);
return authentication;
}
public String resolveToken(HttpServletRequest request) { //토큰 분해/분석
String bearerToken = request.getHeader(AUTHORIZATION);
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER)) {
return bearerToken.substring(7);
}
return null;
}
public boolean validateToken(String token) {
try {
Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token);
return true;
} catch (UnsupportedJwtException | ExpiredJwtException | IllegalArgumentException e) {
return false;
}
}
private Claims parseClaims(String accessToken) {
try {
return Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(accessToken)
.getBody();
} catch (ExpiredJwtException e) {
return e.getClaims();
} catch (SignatureException e) {
throw new RuntimeException("토큰 복호화에 실패했습니다.");
}
}
}
@RequiredArgsConstructor
public class JwtFilter extends GenericFilterBean {
private final TokenProvider tokenProvider;
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain) throws IOException, ServletException {
String token = tokenProvider.resolveToken((HttpServletRequest) request);
if (StringUtils.hasText(token) && tokenProvider.validateToken(token)) {
Authentication authentication = tokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
}
@Configuration
@RequiredArgsConstructor
public class SecurityConfig{
private final TokenProvider tokenprovider;
@Bean
public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
return httpSecurity
.httpBasic(AbstractHttpConfigurer::disable)
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(sessionManagement -> sessionManagement.sessionCreationPolicy(
SessionCreationPolicy.STATELESS))
.formLogin(AbstractHttpConfigurer::disable)
.logout(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(authorizeRequests -> authorizeRequests
.requestMatchers("/gdg/**").permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(new JwtFilter(tokenprovider), UsernamePasswordAuthenticationFilter.class)
.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
}
@Configuration
어노테이션으로 해당 클래스를 설정 클래스로 인식하게해요, 이는 어플리케이션 컨텍스트에 등록해요@Bean
어노테이션을 통해서 Spring 컨텍스트에 SecurityFilterChain 빈을 등록해요httpBasic
: http의 기본이 되는 인증을 비활성화해요 (JWT를 사용하기에)csrf
: CSRF 보호를 비활성화해요 - RESTful API와 Stateless(무상태) 인증을 사용하기에 CSRF 방어가 불필요해요sessionManagement
: 세션 상태를 비저장(statless) 설정하여 서버에서 세션을 사용하지 않도록해요 (JWT를 사용해서 인증 상태를 관리하기 때문이에요)formLogin
: JWT를 사용한 인증이므로 별도의 로그인 폼은 필요없어요authorizeHttpRequests
: /gdg/**
경로에 대한 접근은 인증 없이 허용해요, 그 외 경로는 인증된 사용자만 접근할 수 있어요.addFilterBefore
: 들어오는 요청에 대해 JWT 토큰을 검증하고 인증 정보를 설정해요.@Service
@RequiredArgsConstructor(access = AccessLevel.PROTECTED)
public class UserService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final TokenProvider tokenProvider;
@Transactional
public TokenDto signUp(UserSignUpDto signUpDto) {
User user = userRepository.save(User.builder()
.email(signUpDto.getEmail())
.password(passwordEncoder.encode(signUpDto.getPassword()))
.phoneNumber(signUpDto.getPhoneNumber())
.build());
String accessToken = tokenProvider.createAccessToken(user);
return TokenDto.builder()
.accessToken(accessToken)
.build();
}
@Transactional(readOnly = true)
public UserInfoDto findByPrincipal(Principal principal) {
Long userId = Long.parseLong(principal.getName());
User user = userRepository.findById(userId)
.orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다."));
return UserInfoDto.builder()
.email(user.getEmail())
.phoneNumber(user.getPhoneNumber())
.role(user.getRole().name())
.build();
}
}
@RestController
@RequestMapping("/gdg")
@RequiredArgsConstructor(access = AccessLevel.PROTECTED)
public class UserController {
private final UserService userService;
@PostMapping("/signUp")
public ResponseEntity<TokenDto> signUp(@RequestBody UserSignUpDto userSignUpDto) {
TokenDto response = userService.signUp(userSignUpDto);
return ResponseEntity.ok(response);
}
@GetMapping("/getUser")
public ResponseEntity<UserInfoDto> getUser(Principal principal) {
UserInfoDto userInfoDto = userService.findByPrincipal(principal);
return ResponseEntity.ok(userInfoDto);
}
}
JWT <- 사이트는 브라우저 상에서 JWT 토큰을 검증하고 생성 할 수 있게 해주는 디버거 서비스를 제공해요.
이미지 출처
https://www.google.com/url?sa=i&url=https%3A%2F%2Fcoming-soon.oopy.io%2Fd9934ee3-8095-4d81-b80b-72d2b1742f37&psig=AOvVaw2vfhM9htQCX6kKSoOGrABA&ust=1731022550619000&source=images&cd=vfe&opi=89978449&ved=0CBcQjhxqFwoTCOC50t3vyIkDFQAAAAAdAAAAABAT
https://tansfil.tistory.com/58
https://mokpo.tistory.com/458
참고 자료
https://docs.spring.io/spring-security/reference/servlet/authentication/architecture.html
https://velog.io/@sujin-create/Spring-spring-security%EC%99%80-JWT-%EC%9D%B8%EC%A6%9D-%EC%9D%B8%EA%B0%80-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0
https://velog.io/@qowl880/Spring-Security-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98-ContextHolder
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyLrgpzsnbTrj4QiOiLsg53qsIHrs7Tri6Qg7Ja066C17KOgLi4_IOyggOuPhCDsspjsnYzsl5Ag64SI66y0IOyWtOugpOyboOyWtOyalC4uIiwi7IKs6rO8Ijoi7KeI66y47J2EIOyemO2VtOyVvO2VnOuLpOuKlCDslZXrsJXsnYQg7KSAIOqygyDqsJnslYTshJwg66-47JWI7ZW07JqUIiwi7KeI66y4Ijoi7LKY7J2M67aA7YSwIOyniOusuOydhCDsnpjtlZjripQg7IKs656M7J2AIOyXhuuLpOuKlCDqsbAuLiEiLCJHREdvQyI6IuyEnOuyhCDtjIztirgg7ZmU7J207YyFISIsIuqzvOygnCI6IuyYpOuKmOydtOuCmCDrgrTsnbzspJHsnLzroZwg6rO17KeA7ZWg6rKM7JqUIOOFjuOFjiJ9.8OakS1Jg7n_YFu3CRgf3EIORKeGYe72mmyM9ETO1W8Q