JWT Token을 이용한 인증 설정과 비밀번호 암호화 프로세스
JWT(Json Web Token)이란?
JSON 형식으로 이루어진 클라이언트와 서버 간의 인증 및 인가를 위한 표준화된 방법
주로 웹 애플리케이션에서 사용되며, 사용자 인증 정보를 안전하게 전송하기 위해 설계됨
인터넷 사이트에서 콘서트 티켓을 구입했다고 생각해봅시다.
콘서트홀에 입장할때마다 직원에게 인터넷 사이트 아이디와 비밀번호, 내가 누구인지를 매번 확인해주려면 직원도 나도 힘들거에요.
그래서 간단하게 앱이나 실물로 받은 티켓을 보여주고 입장하죠.
JWT도 이와 비슷합니다.
JWT의 구조
헤더 (Header)
토큰의 유형과 서명 알고리즘을 포함합니다.
예를 들어, JWT의 경우 {"alg": "HS256", "typ": "JWT"}와 같은 JSON 객체로 표현됩니다.
페이로드 (Payload)
사용자의 인증 및 인가 정보를 담고 있으며, 이를 '클레임(claim)'이라고 합니다.
서명 (Signature)
토큰의 정보가 서버에서 생성된 것인지를 확인. 헤더와 페이로드를 비밀키로 서명하여 위변조를 방지합니다.
이 세 부분은 점(.)으로 구분돼서 하나의 긴 문자열로 만들어집니다.
xxxxx.yyyyy.zzzzz
xxxxx 헤더, yyyyy 페이로드, zzzzz 서명
Header와 Payload는 단순히 Base64url로 인코딩되어 있어 누구나 쉽게 복호화할 수 있지만, Signature는 key가 없으면 복호화할 수 없어 보안상 안전합니다.
key 알고리즘은 대표적으로 HS256(비밀키)가 있습니다.
HS256(비밀키) 특징
대칭키 암호화: HS256은 동일한 비밀키를 사용하여 토큰을 생성하고 검증합니다.
보안성: SHA-256 해시 함수를 사용하여 높은 수준의 보안을 제공합니다.
효율성: 비교적 빠른 연산 속도를 제공하여 실시간 인증에 적합합니다.
키 길이: HS256 알고리즘은 최소 256비트(32바이트) 길이의 비밀키를 요구합니다.
JWT의 작동 원리
1. 사용자가 ID와 비밀번호를 입력하고 로그인 API 리퀘스트
2. 서버는 DB에서 사용자를 확인한 후
3. 비밀키를 사용하여 JWT를 생성
4. 생성된 토큰을 클라이언트에 전달
5. 이후 클라이언트는 API 요청 시 이 JWT를 HTTP 헤더에 포함시켜 서버에 전송합니다.
6. 서버는 JWT의 서명을 검증하여
7. 사용자의 인증 여부를 판단하고 요청에 대한 응답을 제공합니다.
JWT의 장점
JWT 등장 전에는 쿠키(cookie)와 세션(session)을 이용한 인증으로
모든 사용자의 세션을 DB나 캐시(cache)에 저장해놓고 쿠키로 넘어온 세션 ID로 사용자 데이터를 매번 조회해야만 했지만
사용자 인증에 필요한 정보를 토큰에 포함(Stateless)하기때문에 서버에 별도의 인증 저장소가 필요없어 확장성이 뛰어납니다.
다양한 프로그래밍 언어에서 지원되며, 토큰 크기가 작아 네트워크 오버헤드도 적습니다.
JWT의 유의점
제 3자가 access token을 탈취해버리거면 서버에 접근이 가능한 문제가 있어요.
그래서 토큰 유효시간 관리와 알고리즘 검증, 비대칭 알고리즘 사용, 안전한 비밀키 사용, 최신 라이브러리를 사용 등으로 보안유지에 주의해야 합니다.
Maven 기준, JWT 기반 로그인 인증 API 개발 예시
1. 서버에 JWT 인증 의존성 추가
pom.xml에 JWT 인증 의존성을 추가합니다.
(저는 Spring Web, Lombok, MySQL Driver, Spring Data JDBC, SpringSecurity도 디펜던시에 이미 추가를 해둔 상태입니다.)
<!-- JWT -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.5</version>
<scope>runtime</scope>
</dependency>
2. Config
설정 전 우선 application.yml에 Secret키를 설정 합니다.
jwt:
secret: 비밀키 문자열을 입력합니다.
보안성 강화: JWT 토큰 생성 및 검증에 사용되는 비밀 키(secret)를 외부 설정 파일에 저장함으로써, 소스 코드에 직접 하드코딩하는 것을 방지합니다.
유연성 확보: 애플리케이션 실행 환경에 따라 쉽게 설정을 변경할 수 있습니다. 개발, 테스트, 운영 환경별로 다른 설정을 사용할 수 있습니다.
알고리즘 요구사항 충족: JWT에서 사용하는 HS512 알고리즘은 64바이트(512비트) 이상의 비밀 키를 요구합니다.
중앙화된 설정 관리: JWT 관련 설정을 application.yml에 집중시켜 관리를 용이하게 합니다.
토큰 유효 기간 설정: JWT 토큰의 만료 시간을 설정할 수 있어, 보안을 더욱 강화할 수 있습니다.
비밀키는 유출되지 않도록 각별히 신경 쓰세요!
설정을 담당해줄 config패키지와 클래스들을 추가합니다.
JwtConfig 클래스
이 클래스는 JWT 토큰 생성 및 파싱을 담당합니다.
key: JWT 서명에 사용될 비밀 키입니다.
tokenValidMilisecond: 토큰의 유효 기간을 설정합니다.
createToken(): 사용자 ID를 받아 JWT 토큰을 생성합니다.
getTokenClaims(): 주어진 토큰에서 클레임(사용자 정보)을 추출합니다.
@Configuration
public class SecurityConfig {
@Autowired
JwtAuthenticationFilter jwtAuthenticationFilter;
// 비밀번호 암호화 처리 클래스 빈 등록
// passwordEncoder는 Spring Security에서 제공하는 인터페이스로
// BCryptPasswordEncoder 클래스를 이용하여 구현
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
// SecurityFilterChain 빈 등록
// JWT 토큰이 유효한지 검사하는 필터 (필터체인)
// JWT안에는 "암호화된" 유저아이디가 들어감
// 토큰발급은 로그인할때
@Bean
// 이 함수 실행되서 SecurityFilterChain 객체를 반환
// SecurityFilterChain 객체는 HttpSecurity 객체를 받아서 필터체인을 설정
// Bean으로 등록되어 있어야 스프링이 관리
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(authorizeRequests ->
authorizeRequests
.requestMatchers("/api/users/signup", "/api/users/login").permitAll()
// 인증 토큰이 없어도 네트워크 통신이 되어야 하는 URL 패턴 설정
// 회원가입과 로그인은 JWT를 사용하기 때문에 토큰이 없어도 네트워크 통신이 되어야 한다.
.anyRequest().authenticated())
// filter를 추가하는 메소드
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
SecurityConfig 클래스
이 클래스는 Spring Security의 전반적인 보안 설정을 담당합니다.
passwordEncoder(): 비밀번호 암호화에 사용될 BCryptPasswordEncoder를 빈으로 등록합니다.
securityFilterChain():
- 보안 필터 체인을 구성합니다.
- CSRF 보호를 비활성화합니다.
- "/api/users/signup"과 "/api/users/login" 경로는 인증 없이 접근 가능하도록 설정합니다. 그 외의 모든 요청은 인증이 필요합니다.
- JWT 인증 필터를 UsernamePasswordAuthenticationFilter 앞에 추가합니다.
@Configuration
public class JwtConfig {
Key key;
// 토큰 만료 시간 설정 24 * 60 * 60 * 1000 24시간
long tokenValidMilisecond = 24 * 60 * 60 * 1000;
public JwtConfig(@Value("${jwt.secret}")String secretKey){
this.key = Keys.hmacShaKeyFor(secretKey.getBytes());
}
// application.yml 에서 jwt.secret 값을 가져온다.
// secretKey.getBytes() - secretKey를 바이트로 변환
// DB에 비번, 이메일, 닉네임 저장 > 로그인때 정보 맞는지 확인하고 토큰 발급 > 토큰으로 정보 확인
// todo 토큰 생성 함수
public String createToken(Long userId){
Date now = new Date();
// 현재 시간 년월일시분초 가져온다
Date validity = new Date (now.getTime() + tokenValidMilisecond);
// 토큰 만료 시간 설정 현재시간에 만료시간을 더해준다
return Jwts.builder()
.subject(userId.toString())
.issuedAt(now)
.expiration(validity)
.signWith(key, SignatureAlgorithm.HS256)
.compact();
// 토큰 생성
}
// todo 토큰에 저장된 데이터(유저 아이디)를 가져오는 함수
public Claims getTokenClaims(String token){
return Jwts.parser().
verifyWith((SecretKey)key)
.build()
.parseClaimsJws(token)
.getPayload();
// 토큰에 저장된 데이터(유저 아이디)를 가져온다
}
이 설정을 통해 사용자는 로그인시 JWT 토큰을 받아, 이 토큰을 통해 인증받을 수 있습니다.
3. Filter
스프링 시큐리티 필터는 웹 애플리케이션의 보안을 관리하는 핵심 구성 요소입니다.
이 필터들은 HTTP 요청이 컨트롤러에 도달하기 전에 작동하여 인증과 인가를 처리합니다.
필터 체인의 구조
스프링 시큐리티는 여러 필터로 구성된 필터 체인을 사용합니다. 각 필터는 특정 보안 기능을 담당하며, 순차적으로 실행됩니다.
필터 체인은 다중으로 설정할 수 있으며, URL 패턴별로 다른 권한 체크를 구성할 수 있습니다.
주요 필터
SecurityContextPersistenceFilter: SecurityContext를 로드하고 저장하는 역할을 합니다.
UsernamePasswordAuthenticationFilter: 사용자 인증을 처리합니다.
AnonymousAuthenticationFilter: 인증되지 않은 요청을 익명 사용자로 처리합니다.
ExceptionTranslationFilter: 보안 예외를 처리합니다.
FilterSecurityInterceptor: 접근 권한을 최종적으로 확인합니다.
필터 커스터마이징
개발자는 필요에 따라 커스텀 필터를 추가하거나 기존 필터를 수정할 수 있습니다.
토큰이 없는 사람이 회원가입 하려할 때, 로그인 하려할 때까지 토큰이 없음 접근불가라고 길을 막아서면 안되겠죠?
위 config에서 설정했던것처럼 두 url은 토큰이 없어도 접근 가능하도록 설정하기위한 커스텀 필터를 추가하겠습니다.
@Component
// 필터 규칙
// 상속받아 만들어야한다 extends OncePerRequestFilter
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Autowired
private JwtConfig jwtConfig;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
// signup, login은 토큰이 없어도 네트워크 통신이 되어야 한다.
if(request.getRequestURI().equals("/api/users/signup")||
request.getRequestURI().equals("/api/users/login")){
filterChain.doFilter(request,response);
return;
}
// 헤더에서 토큰을 가져온다.
String bearerToken = request.getHeader("Authorization");
//리퀘스트 헤더의 Authorization (요상한 문자열) 을 가져와서 bearerToken에 저장
if(bearerToken == null||bearerToken.isEmpty()||!bearerToken.startsWith("Bearer ")){
//토큰이 없거나 비어있거나 "Bearer "로 시작하지 않으면
response.setStatus(401);
return;
//401 인증에러
}
// 만료날짜, 생성일자 확인
String token = bearerToken.substring(7);
// "Bearer " 7 번째부터 끝까지 순수토큰값만 가져온다.
// 토큰 유효시간 검증
Claims claims = jwtConfig.getTokenClaims(token);
Date expiration=claims.getExpiration();
if(expiration != null && expiration.before(new Date())){
// expiration이 null이 아니고 현재시간보다 이전이면
response.setStatus(401);
// 401 인증에러
return;
}
// 토큰이 유효하면 다음 필터로 넘어간다.
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(claims.getSubject(),
null, null);
// SecruityContextHolder에 인증정보를 넣어준다.
SecurityContextHolder.getContext().setAuthentication(authentication);
// SecurityConfig에 .addFilterBefore
filterChain.doFilter(request, response);
}
4. Spring Security를 이용한 비밀번호 암호화
보안을 더 업그레이드 해봅시다.
지금까지 예제에서는 비밀번호를 리퀘스트에 들어온 그대로 저장했습니다.
이제부터는 중요한 비밀번호가 누출되지 않도록 암호화를 거쳐 DB에 저장되게 하겠습니다.
PasswordEncoder를 주입합니다.
@Autowired
private PasswordEncoder passwordEncoder;
// * ServiceClass
//todo 회원가입
//- 이메일 중복 확인을 수행합니다. DB가 해줄거임
//- 비밀번호는 암호화하여 저장됩니다. JWT 토큰
//- 기본 사용자 권한(USER)으로 생성됩니다.
public int signUp(UserRequest userRequest) {
if (!EmailValidator.isValidEmail(userRequest.email)) {
System.out.println("이메일 형식이 아닙니다.");
return 1;
// 이메일 형식 체크
}
if (!PasswordValidator.isValidPassword(userRequest.password)) {
System.out.println("비밀번호 형식이 아닙니다.");
return 2;
// 비밀번호는 최소 8자 이상, 영문/숫자/특수문자 조합
}
if (!InputValidator.isValidInput(userRequest.nickname)) {
System.out.println("닉네임 형식이 아닙니다.");
return 3;
// 닉네임은 최소 2자 이상, 20자 이내, 한글,영문,숫자허용
}
// 비밀번호 저장 전 암호화 한다 passwordEncoder.encode (스프링 시큐리티)
String password = passwordEncoder.encode(userRequest.password);
userRequest.password = password;
try {
userDAO.signUp(userRequest);
} catch (Exception e) {
System.out.println("동일한 이메일로 회원가입을 시도할 경우");
return 4;
}
return 0;
}
config-SecurityConfig에서 추가해 둔 passwordEncoder. 메서드를 이용하여 회원가입 시 비밀번호를 암호화합니다.
String password = passwordEncoder.encode(userRequest.password);
userRequest.password = password;
이제 회원가입 API 리퀘스트시 암호화된 비밀번호로 저장 됩니다.
// todo 로그인
public Object userLogin(UserRequest userRequest){
// 이메일 형식 체크
if (EmailValidator.isValidEmail(userRequest.email) == false) {
return 1;
}
// DB로 부터 유저 정보를 받아온다.
try{
User user = userDAO.userLogin(userRequest);
System.out.println("userId : " + user.id);
// 비밀번호가 맞는지 확인한다.
if( passwordEncoder.matches( userRequest.password ,user.password) == false){
// 비밀번호가 틀린경우.
System.out.println(userRequest.password);
System.out.println(user.password);
return 3;
}
System.out.println("ok : "+userRequest.password);
System.out.println("ok : "+user.password);
// 인증토큰을 발급한다. JWT 토큰을 발급한다.
// 가장 중요한 데이터는 유저 아이디다.
// 유저 아이디를 토대로 JWT 토큰을 발급한다.
String token = jwtConfig.createToken(user.id);
System.out.println("token : " + token);
Claims claims = jwtConfig.getTokenClaims(token);
System.out.println("userId : " + claims.getSubject());
return token;
} catch (Exception e) {
// 이메일이 없는 경우
return 2;
}
로그인시에도 passwordEncoder. 메서드를 이용하여 비밀번호를 확인합니다.
User user = userDAO.userLogin(userRequest);
System.out.println("userId : " + user.id);
// 비밀번호가 맞는지 확인한다.
if( passwordEncoder.matches( userRequest.password ,user.password) == false){
// 비밀번호가 틀린경우.
System.out.println(userRequest.password);
System.out.println(user.password);
return 3;
5. 로그인 시 JWT 토큰 발급
//todo 로그인
public Object userLogin(UserRequest userRequest){
Object result = userDAO.userLogin(userRequest);
// 이메일 형식 체크
if (!EmailValidator.isValidEmail(userRequest.email)) {
System.out.println("이메일 형식이 아닙니다.");
return 1;
}
// DB로 부터 유저 정보를 받아온다.
try{
//user 를 받아옴
User user = userDAO.userLogin(userRequest);
// 비밀번호가 맞는지 확인한다. passwordEncoder 스프링부트 시큐리티 라이브러리
if(!passwordEncoder.matches(userRequest.password, user.password)){
// 비밀번호가 틀린경우.
System.out.println("비밀번호가 틀립니다.");
return 3;
}
// 비밀번호가 맞으면 유저 아이디를 토대로 JWT 토큰을 발급한다.
String token = jwtConfig.createToken(user.id);
return token;
} catch (Exception e) {
// 유저가 없는 경우
System.out.println("유저가 없습니다.");
return 2;
}
}
// ServiceClass
// 비밀번호가 맞으면 유저 아이디를 토대로 JWT 토큰을 발급한다.
String token = jwtConfig.createToken(user.id);
return token;
jwtConfig.createToken으로 토큰을 발급합니다.
로그인 시 유저아이디 정보가 저장된 토큰이 리스폰스 됩니다.
6. JWT 인증 적용해보기
SecurityConfig와 JwtAuthenticationFilter 설정때 우리는 회원가입과 로그인 URI 만 빼고 모든 부분에서 인증이 필요하도록 설정했습니다.
그러면 클라이언트가 발급 받은 JWT은 어디로 보내면 될까요?
클라이언트가 서버에 요청을 보낼 때 HTTP 요청 헤더의 "Authorization" 필드에 포함시켜 전송합니다.
유효하지 않은 토큰이거나 토큰이 만료된 경우 401 에러가 출력되게 됩니다.
지금까지 JWT 토큰과 보안 설정에 대해 알아보았습니다.
보충이나, 수정이 필요한 부분이 있으면 댓글로 알려주시면 감사하겠습니다.