서론
캘린더 프로젝트에서 회원가입 기능을 먼저 구현한 후, 로그인 기능을 추가하기 위해 공부하며 작성했습니다. 그 중에서도 로그인할 때 회원을 어떻게 인증할 지에 대해서 검색을 시작해서 Spring Security를 사용하며 JWT를 사용한 인증을 선택했습니다. 특히 JWT의 공부를 시작할 때 버전으로 인해 검색이 되게 어려움이 있었는데 아래의 버전을 사용했으니 참고하시면 되겠습니다.
java 17, SpringBoot 3.3.1, Spring 6.1.10, JWT 0.11.5
JWT에 대해서
JWT란?
JWT (JSON Web Token)는 인증과 정보를 안전하게 전송하기 위해 사용하는 개방형 표준 (RFC 7519)입니다. JWT는 주로 세 가지 구성 요소로 이루어져 있습니다.
- Header: 토큰의 유형과 사용된 서명 알고리즘을 지정합니다. 일반적으로 {"alg": "HS256", "typ": "JWT"}와 같은 형태입니다.
- Payload: 전송할 데이터(주장)를 포함합니다. 데이터는 키-값 쌍으로 구성되며, 주장을 표현하는 데 사용됩니다. 이 데이터는 누구나 읽을 수 있지만, 민감한 정보를 포함하지 않는 것이 좋습니다.
- Signature: Header와 Payload를 인코딩한 후 비밀 키를 사용하여 서명한 것입니다. 이 서명은 토큰의 무결성을 보장하고, 토큰이 변조되지 않았음을 확인하는 데 사용됩니다.
JWT 토큰의 흐름
- 유저 로그인 요청
사용자가 로그인 양식에 이메일과 비밀번호를 입력하고 로그인 버튼을 클릭합니다. 이 요청은 서버에 전송됩니다. - 로그인 정보 인증
서버는 전달받은 로그인 정보를 검증하고, 데이터베이스에 저장된 사용자 정보와 비교하여 인증을 수행합니다. 이 과정에서 입력된 비밀번호는 일반적으로 해시화된 비밀번호와 비교됩니다. - JWT 토큰 발급
인증이 성공하면 서버는 사용자를 위한 JWT(JSON Web Token)를 생성합니다. 이 토큰에는 사용자 정보와 만료 시간 등의 정보가 포함되어 있으며, 서버의 비밀 키로 서명됩니다. - 유저의 저장소에 JWT 토큰 저장
발급된 JWT 토큰은 사용자의 클라이언트(예: 로컬 저장소, 쿠키, 세션 등)에 저장됩니다. 이를 통해 사용자는 이후의 요청에서 이 토큰을 사용할 수 있습니다. - 유저가 인증이 필요한 데이터를 요청할 때 JWT 토큰과 함께 전송
사용자가 인증이 필요한 API에 접근할 때, 클라이언트는 저장된 JWT 토큰을 HTTP 요청 헤더의 Authorization 필드에 담아 전송합니다. - 서버에서 JWT 토큰 인증
서버는 수신한 JWT 토큰을 검증합니다. 이 과정에서 토큰의 유효성(만료 여부, 서명 검증 등)을 확인합니다. 토큰이 유효하면 해당 사용자의 인증 정보를 SecurityContext에 설정합니다. - 인증이 성공하면 데이터 반환
인증이 성공한 후, 서버는 요청한 데이터를 반환합니다. 이 데이터는 클라이언트에서 사용자에게 표시됩니다.
의존성 설정
이 프로젝트에서는 JWT(JSON Web Token)를 사용하기 위해 Gradle에 필요한 의존성을 추가했습니다.
//gradle
// spring security
implementation "org.springframework.boot:spring-boot-starter-security"
// JWT 라이브러리
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'
JWT 토큰의 흐름에 따른 로그인 서비스 개발
1. 유저 로그인 요청
<!-- html -->
<div class="form-container">
<h2>Sign in</h2>
<form id="loginForm">
<input type="text" name="email" placeholder="Email" required />
<input type="password" name="password" placeholder="Password" required />
<button class="btn-signin" type="submit">Sign in</button>
</form>
<button class="signup-btn" onclick="location.href='/register'">Sign up</button>
<br><div th:if="${message}" style="color: green;">[[${message}]]</div>
</div>
<script>
document.getElementById("loginForm").addEventListener("submit", function(event) {
event.preventDefault();
const formData = new FormData(this);
const jsonData = JSON.stringify({
email: formData.get("email"),
password: formData.get("password")
});
fetch("/login/users", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: jsonData
})
.then(response => response.json())
.then(data => {
if (data.accessToken) {
localStorage.setItem('jwtToken', data.accessToken);
window.location.href = '/index.html';
} else {
alert("Login failed");
}
})
.catch(error => console.error("Error:", error));
});
</script>
유저가 로그인 폼에 작성한 내용을 post로 보내고 반환이 되면 해당 토큰을 로컬저장소에 저장합니다. 그리고 지정한 페이지로 이동하게 됩니다.
//Controller
@RestController
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
@PostMapping("/login/users")
public ResponseEntity<?> loginUser(@Validated @RequestBody UserDto userRequest) {
try {
String token = userService.loginUser(userRequest);
return ResponseEntity.ok(new JwtAuthenticationResponseDto(token, "Login successful!"));
} catch (IllegalArgumentException ex) {
return ResponseEntity.badRequest().body("{\"message\": \"" + ex.getMessage() + "\"}");
}
}
}
Controller에서는 @RestController를 사용했는데 받아야할 토큰인 JWT(JsonWebToken)가 JSON형식이기 때문에 사용했습니다. 그래서 스크립트에서 폼정보를 json에서 변환해서 가져옵니다. 로그인 요청이 성공한다면 토큰을 반환해줍니다.
2. 로그인 정보 인증
로그인 서비스에서의 유저 정보 인증
//Service
private final AuthenticationManager authenticationManager;
private final JwtTokenProvider jwtTokenProvider;
@Transactional
public String loginUser(UserDto requestUser) {
try {
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(requestUser.getEmail(), requestUser.getPassword())
);
return jwtTokenProvider.generateToken(authentication);
} catch (AuthenticationException e) {
throw new IllegalArgumentException("Invalid credentials provided.");
}
}
authenticationManager.authenticate 메소드는 UsernamePasswordAuthenticationToken 객체를 생성하여 입력된 이메일과 비밀번호를 사용하여 인증을 시도합니다. 이 객체는 사용자의 인증 정보를 나타냅니다.
인증 과정에서 Spring Security는 UserDetailsService를 통해 사용자 정보를 확인하고, 입력된 비밀번호가 저장된 비밀번호와 일치하는지 검증합니다.
authenticationManager는 Spring Security에서 제공하는 인터페이스로, 전체 인증 프로세스를 담당하며, 인증이 성공하면 Authentication 객체를 반환합니다. 이 객체에는 인증된 사용자의 정보와 권한(예: ROLE_USER, ROLE_ADMIN 등)이 포함됩니다.
//SecurityConfig
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}
AuthenticationConfiguration을 사용하여 AuthenticationManager 인스턴스를 생성하고 반환합니다.
AuthenticationConfiguration은 Spring Security의 인증 관련 설정을 관리하며, getAuthenticationManager() 메서드를 호출하여 현재 애플리케이션의 인증 설정에 따라 적절한 AuthenticationManager를 반환합니다.
유저 정보 반환
@Service
public class CustomUserDetailsService implements UserDetailsService {
@Autowired
private UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
UserEntity userEntity = userRepository.findByEmail(email)
.orElseThrow(() -> new UsernameNotFoundException("User not found with email: " + email));
return new org.springframework.security.core.userdetails.User(
userEntity.getEmail(),
userEntity.getPassword(),
new ArrayList<>() // 권한 없이 빈 리스트로 처리
);
}
}
유저 정보를 반환하기 위해 UserDetailsService 인터페이스를 구현합니다. UserDetailsService는 사용자의 세부 정보를 로드하는 역할을 수행합니다. 그 중 loadUserByUsername 메서드는 사용자의 이메일을 기반으로 데이터베이스에서 해당 사용자를 조회합니다. 유저 정보에는 데이터베이스에서 가져온 사용자 ID와 비밀번호가 포함됩니다.
이 과정에서 사용자가 존재하지 않는 경우 UsernameNotFoundException이 던져지며, 이로 인해 인증 프로세스가 중단됩니다. 사용자 정보가 존재할 경우, 이메일, 비밀번호, 그리고 권한 정보가 포함된 UserDetails 객체를 반환합니다. 이때 UserDetails는 UsernamePasswordAuthenticationToken을 사용하여 생성된 인증 객체의 정보와 일치해야 합니다.
UserDetailsService를 따로 구현한 이유
구현하지 않을 경우 스택 오버플로우가 나타났습니다. 정확한 원인은 파악은 못했지만 UserDetailsService가 없을 때 계속해서 재귀를 하는 정황상 해당 유저정보를 가져오기 위해서 시도할 때 제대로 된 정보를 가져오지 못해 실패하게 되는 것으로 생각됩니다.
3. JWT 토큰 발급
//JwtTokenProvider
public String generateToken(Authentication authentication) {
String username = ((UserDetails) authentication.getPrincipal()).getUsername();
long EXPIRATION_TIME = 86400000; // 24시간
return Jwts.builder()
.setSubject(username)
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + EXPIRATION_TIME))
.signWith(key, SignatureAlgorithm.HS512)
.compact();
}
인증이 완료되면 JwtTokenProvider에서 JWT 토큰을 생성합니다. 이 과정에서 JWT에는 사용자 이름, 발급 시간, 만료 시간이 포함되어 보안성이 강화됩니다. JWT를 생성할 때는 안전하게 저장된 암호화된 시크릿 키를 사용하여 토큰의 서명을 생성합니다. 이렇게 함으로써 JWT의 진위성과 무결성을 보장할 수 있습니다.
시크릿키 설정
//JwtTokenProvider
@Value("${JWT_SECRET}")
private String SECRET_KEY;
private Key key;
@PostConstruct
public void init() {
this.key = Keys.hmacShaKeyFor(Decoders.BASE64.decode(SECRET_KEY));
}
//application.yml
jwt:
secret: ${JWT_SECRET}
시크릿 키는 JWT의 서명을 생성하고 검증하는 데 사용되며, 이 키는 안전하게 보호되어야 합니다. 이 키가 유출될 경우 JWT의 안전성이 무너질 수 있습니다. 저의 경우에는 인텔리제이의 환경 변수에 추가하여 사용하고 있습니다. 시크릿키는 콘솔에서 openssl rand -hex 64 명령어를 통해 랜덤한 값을 생성했습니다. 현재는 사용하기 쉽게 인텔리제이에서 환경변수를 이용해서 시크릿 키를 사용하고 있지만, 향후에는 AWS의 시크릿 키 관리 기능을 활용해볼 계획입니다.
@PostConstruct 애너테이션이 붙은 init() 메서드는 Spring의 Dependency Injection이 완료된 후 자동으로 호출됩니다. 이 메서드에서는 환경 변수로부터 읽어온 시크릿 키를 Base64로 디코딩하고, HMAC 키로 변환하여 JWT 서명 및 검증에 사용할 준비를 합니다. HMAC 키를 생성한 후, 이 키를 사용하여 JWT를 서명하고 검증함으로써 JWT의 진위성과 무결성을 보장하는 역할을 하게 됩니다.
4. 유저의 저장소에 JWT 토큰 저장
fetch("/login/users", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: jsonData
})
.then(response => response.json())
.then(data => {
if (data.accessToken) {
localStorage.setItem('jwtToken', data.accessToken);
window.location.href = '/index.html';
} else {
alert("Login failed");
}
})
.catch(error => console.error("Error:", error));
localStorage.setItem()을 통해서 반환받은 토큰 정보를 저장해주었습니다. 저장하는 방법은 세션, 쿠키, 로컬저장소가 있는데 저는 로컬 저장소로 사용했습니다. 그러나 민감한 정보를 저장할 때는 보안상의 이유로 쿠키를 이용한 HttpOnly 설정과 같은 다른 방법을 고려해야 합니다.
로컬 저장소를 사용하여 JWT 토큰을 저장한 이유는?
- 영속성: 로컬 저장소에 저장된 데이터는 브라우저 종료나 페이지 새로 고침 시에도 유지되므로, 사용자가 페이지를 다시 방문할 때 이전 상태를 유지할 수 있습니다.
- 용이한 접근성: JavaScript를 통해 간편하게 접근할 수 있으며, 데이터를 비동기적으로 읽고 쓸 수 있어 사용자 인증 정보를 쉽게 관리할 수 있습니다.
만약에 다른 페이지로 이동하고 싶은데 원하는 페이지로 이동이 안 된다면?
처음에는 로그인 페이지와 회원가입 페이지만 권한없이 진입을 허용을 했는데, 토큰의 권한을 유지한 채로 html 페이지 이동이 있을경우 인증처리를 어떻게 해야되나 걱정이였습니다. SecurityConfig의 securityFilterChain 메서드에서 authorizeHttpRequests의 허용 사이트에 추가해주고 인증이 필요한 요청시에는 유저 인증을 하도록 처리를 했습니다. 추후에는 SPA나 쿠키를 통한 처리를 나중에 해봐야겠습니다.
5. 유저가 인증이 필요한 데이터를 요청할 때 JWT 토큰과 함께 전송
fetch(`/schedules/date?start=${dateStr}&end=${dateStr}`, {
method: 'GET',
headers: {
'Authorization': 'Bearer ' + localStorage.getItem('jwtToken'),
'Content-Type': 'application/json'
}
})
Spring Security의 자동 인증 처리
Spring Security는 요청 헤더의 Authorization 정보를 자동으로 감지하여 JWT 토큰을 처리합니다. JwtAuthenticationFilter가 요청을 가로채어 다음과 같은 과정을 수행합니다.
6. 서버에서 JWT 토큰 인증
JWT 토큰 인증 흐름
- 클라이언트 요청: 클라이언트는 특정 API에 요청을 보낼 때, 로컬 저장소에 저장된 JWT 토큰을 Authorization 헤더에 포함시켜 요청합니다.
- 요청 수신: Spring Boot 애플리케이션의 엔드포인트에 요청이 도착하면, SecurityFilterChain이 이를 가로챕니다. 이 체인은 여러 필터를 순차적으로 실행합니다.
- JWT 인증 필터: SecurityFilterChain 내에서 설정된 필터 중 JwtAuthenticationFilter가 요청을 처리합니다. 이 필터는 JWT 토큰을 검증하는 기능을 담당합니다.
- 토큰 추출 및 검증: JwtAuthenticationFilter의 doFilterInternal 메서드에서 JWT 토큰을 요청 헤더에서 추출하고(resolveToken), 유효성을 검사합니다(validateToken).
- 사용자 인증 정보 설정: 토큰이 유효한 경우, getAuthentication 메서드를 통해 사용자 정보를 추출하여 Authentication 객체를 생성합니다. 이 객체는 SecurityContextHolder에 저장되어, 현재 요청의 인증 정보를 관리합니다.
- 요청 처리: 인증된 사용자는 보호된 자원에 접근할 수 있으며, 유효하지 않은 토큰을 가진 사용자는 접근이 차단됩니다. 필터 체인은 다음 필터로 요청을 전달하고, 최종적으로 컨트롤러로 이동하여 요청을 처리합니다.
- SecurityFilterChain 설정: SecurityConfig 클래스에서 SecurityFilterChain을 설정하여, 특정 경로에 대한 접근 권한을 지정합니다. 예를 들어, 로그인 및 회원가입 API는 누구나 접근할 수 있도록 허용하고, 그 외의 모든 요청은 인증이 필요하도록 설정합니다.
토큰 인증을 하기위해서 먼저 JwtAuthenticationFilter, JwtTokenProvider, SecurityConfig의 설정이 필요합니다.
JwtAuthenticationFilter
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtTokenProvider;
public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider) {
this.jwtTokenProvider = jwtTokenProvider;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
String token = jwtTokenProvider.resolveToken(request);
if (token != null && jwtTokenProvider.validateToken(token)) {
Authentication auth = jwtTokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(auth);
}
filterChain.doFilter(request, response);
}
}
JwtAuthenticationFilter는 HTTP 요청을 필터링하여 JWT 토큰을 확인하는 역할을 합니다. 이 필터는 사용자가 인증이 필요한 요청을 보낼 때마다 실행됩니다.
doFilterInternal 메서드는 JWT 기반 인증 시스템에서 중요한 역할을 합니다. 이 메서드는 요청을 처리하고, JWT 토큰을 검증하여 사용자 인증 정보를 설정합니다. 이를 통해 인증된 사용자는 보호된 자원에 접근할 수 있으며, 유효하지 않은 토큰을 가진 사용자는 접근이 차단됩니다. Spring Security의 필터 체인에 의해 자동으로 호출되며, 클라이언트의 HTTP 요청이 들어올 때마다 JWT 토큰의 유효성을 검사하고 사용자 인증 정보를 설정하는 역할을 합니다.
JwtTokenProvider
//JwtTokenProvider
// HTTP 요청에서 토큰 추출
public String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7); // Bearer 접두사 제거
}
return null;
}
// JWT 토큰 검증
public boolean validateToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token); // 토큰 유효성 검사
return true;
} catch (JwtException | IllegalArgumentException e) {
return false; // 유효하지 않은 경우
}
}
// 토큰에서 Authentication 객체 추출
public Authentication getAuthentication(String token) {
String username = getUsernameFromToken(token); // 토큰에서 사용자 이름 추출
return new UsernamePasswordAuthenticationToken(username, "", Collections.emptyList()); // 인증 정보 생성
}
JwtTokenProvider는 JWT 토큰을 생성하고 검증하는 역할을 합니다.
resolveToken 메서드는 HTTP 요청에서 JWT 토큰을 추출합니다.
validateToken는 주어진 JWT 토큰의 유효성을 검사합니다. Jwts.parserBuilder()를 사용하여 JWT의 서명을 검증하고, 토큰의 유효 기간이 만료되지 않았는지 확인합니다. 유효한 경우 true를 반환하고, 예외가 발생하면(예: 토큰이 만료되었거나 잘못된 서명일 경우) false를 반환합니다.
getAuthentication 메서드는 유효한 토큰에서 사용자 인증 정보를 생성합니다.
SecurityConfig
//SecurityConfig
@Configuration
@EnableWebSecurity
public class SecurityConfig {
private final JwtTokenProvider jwtTokenProvider;
@Autowired
public SecurityConfig(JwtTokenProvider jwtTokenProvider) {
this.jwtTokenProvider = jwtTokenProvider;
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(sessionManagement ->
sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 상태 비저장 세션 설정
)
.authorizeHttpRequests(auth -> auth
.requestMatchers("/login/**", "/register/**").permitAll() // 로그인, 회원가입 API 접근 허용
.anyRequest().authenticated()
)
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class); // JWT 필터 추가
return http.build();
}
}
securityFilterChain 메서드는 HTTP 요청을 처리하는 필터 체인을 구성하고, JWT 기반 인증을 설정합니다.
CSRF 보호 비활성화: csrf(AbstractHttpConfigurer::disable)를 통해 CSRF(Cross-Site Request Forgery) 보호를 비활성화합니다. JWT 기반 인증에서는 주로 API 요청을 처리하므로, CSRF 공격에 대한 걱정이 상대적으로 적습니다.
세션 관리 설정: sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)를 설정하여, Spring Security가 세션을 관리하지 않도록 합니다. 이는 JWT 토큰이 클라이언트 측에 저장되고, 서버에서는 상태를 유지하지 않기 때문입니다.
HTTP 요청 권한 부여: authorizeHttpRequests를 사용하여 요청에 대한 접근 제어를 설정합니다. /login/** 및 /register/** 경로는 모든 사용자에게 허용되고, 그 외의 요청은 인증된 사용자만 접근할 수 있습니다.
JWT 필터 추가: addFilterBefore 메서드를 사용하여 JwtAuthenticationFilter를 추가합니다. 이 필터는 모든 요청에 대해 JWT 토큰을 검증하고, 인증 정보를 설정합니다. UsernamePasswordAuthenticationFilter 전에 실행되도록 설정하여, 일반 인증 과정 전에 JWT 인증을 처리할 수 있습니다.
7. 인증이 성공하면 데이터 반환
인증이 성공하면 요청한 API 엔드포인트가 활성화되어 필요한 데이터가 반환되며, 클라이언트는 이 데이터를 받아 처리하게 됩니다.
마치며
처음에는 하나하나 이게 뭔가 싶었는데 단계별로 흐름대로 타고 가보니 이해가 되었어서 흐름순으로 최대한 작성해보았습니다. 도움이 되셨으면 좋겠습니다. 위 내용을 학습하기 위해 구글링과 인프런의 무료 JWT 강의를 참고했습니다. 강의 내용이 이전 버전이라 다소 어려웠지만, 기본 개념을 이해하는 데에는 적합했습니다. 만약 최신 버전의 내용을 배우고 싶으시다면, 다른 유료 강의를 추천드립니다.
코드 때문에 긴 글이 된 감이 있는데 끝까지 읽어주셔서 감사합니다.
'Backend Programming' 카테고리의 다른 글
인터넷의 작동 원리 (0) | 2024.10.04 |
---|---|
HTTPS에 대해서 (0) | 2024.09.29 |
회원가입 서비스 만들기 (2) | 2024.09.20 |
DTO에서 NotNull설정이 되어 있을 때 PATCH를 어떻게 해줘야할까? (1) | 2024.08.26 |
RESTful API란? (0) | 2024.07.03 |