프로젝트 전체 코드: https://github.com/kcm02/JWT_OAuth_Login.git
웹 개발에서 로그인이란 사용자가 시스템에 접근하기 위해 자신의 신원을 인증하는 과정을 의미한다. 주로 사용자는 아이디(or 이메일)과 비밀번호를 입력하여 인증을 수행한다. 로그인 과정은 다음과 같다.
1) 인증 정보 제출: 사용자는 로그인 폼에서 형식에 맞게 정보를 입력한다.
2) 서버에서 인증: 서버는 사용자가 제공한 정보를 검증하고, 사용자가 사이트에 등록된 사용자인지 확인한다.
3) 토큰 제공: 인증이 성공하면 서버는 사용자에게 인증 토큰JWT
을 제공하여 이후 요청에서 사용자를 인증할 수 있도록 한다.
4) 접근 권한 부여: 인증된 사용자는 로그인 이후에는 해당 사용자에게 허용된 기능과 데이터에 접근할 수 있다.
JWT
토큰에 대한 내용은 로그아웃 등의 기능에서도 다룰 예정이다. 여기서는 일단 로그인과 관련된 것만 다루도록 하겠다.
com.project.securelogin
├── config
│ └── SecurityConfig.java
├── controller
│ └── AuthController.java
│ └── UserController.java
├── domain
│ └── CustomUserDetails.java
│ └── User.java
├── dto
│ └── UserRequestDTO.java
│ └── UserResponseDTO.java
├── jwt
│ └── JwtAuthenticationFilter.java
│ └── JwtTokenProvider.java
├── repository
│ └── UserRepository.java
└── service
└── AuthService.java
└── CustomUserDetailsService.java
└── UserService.java
AuthController
: 인증 관련 REST API
엔드포인트를 처리하는 컨트롤러 클래스이다. UserController
가 기본적인 CRUD 작업을 담당하는 반면 이 클래스는 인증과 관련된 작업(로그인, 로그아웃 등)을 담당한다.
JwtAuthenticationFilter
: JWT 인증을 처리하는 Spring Security
필터 클래스이다. HTTP 요청에서 JWT 토큰을 추출하고, 유효성 검사를 수행하여 사용자 인증을 처리한다.
JwtTokenProvider
: JWT 토큰 생성 및 검증을 담당하는 유틸리티 클래스이다. 토큰 생성, 정보 추출, 유효성 검사 등의 기능을 제공한다.
AuthService
: 인증 관련 비즈니스 로직을 처리하는 서비스 클래스이다. 사용자 로그인, 토큰 발급 등 인증 관련 작업을 수행한다.
CustomUserDetailsService
: Spring Security의 UserDetailsService
인터페이스를 구현하여 사용자 정보를 로드하는 서비스 클래스이다. 조회된 사용자 정보를 기반으로 CustomUserDetails
객체 생성 후 반환하여 인증 시 사용한다.
로그인 기능에서 필요한 의존성을 pom.xml
의 dependencies
에 추가한다.
<dependencies>
··· 생략 ···
<!-- JWT (JSON Web Token) -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version> <!-- 최신 버전으로 업데이트 가능 -->
</dependency>
··· 생략 ···
</dependencies>
JWT (JSON Web Token)
: 사용자가 로그인을 성공하면 서버는 JWT
를 발급하여 클라이언트에게 전달한다. 이 Token
에는 식별 정보(iD
등)이 담겨있어 서버는 간단히 사용자를 인증하고 관리할 수 있다.Security
와 같은 의존성들은 전 게시물에 추가한 내용이 있으니 전 게시물들을 참고하자!
application.yml
jwt:
secret: (시크릿 키) # JWT 토큰을 서명할 때 사용할 시크릿 키
token-validity-in-seconds: 3600 # 토큰 유효 기간
refresh-token-validity-in-seconds: 86400 # 리프레시 토큰 유효 기간
JWT의 secret
키는 보안과 연관되어 있는 만큼 외부에 노출되지 않도록 신경써야 한다. Access Token
은 짧게, Refresh Token
은 길게 설정하는 것이 일반적이다.
💡 토큰의 유효 기간 설정
금융 서비스와 같이 민감한 정보를 다루는 플랫폼에서는 보안을 강화하기 위해 토큰의 유효 기간을 짧게 설정한다. 토큰이 유출될 경우 접근 가능한 시간을 제한하여 보안을 강화하기 위함이다. 그러나 유효 기간이 짧을 수록 사용자는 자주 로그인을 해야 하는 번거로움이 생길 수 있으니 애플리케이션의 보안 수준에 맞게 적절히 설정해야 한다.
Config
SecurityConfig
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final CustomUserDetailsService customUserDetailsService;
private final JwtTokenProvider jwtTokenProvider;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
httpSecurity
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/login", "/auth/login", "/user/signup", "/").permitAll()
.anyRequest().authenticated()
)
.formLogin(form -> form
.permitAll() // 로그인 페이지는 누구나 접근 가능
)
// JWT 인증 필터 추가
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider,
customUserDetailsService), UsernamePasswordAuthenticationFilter.class);
return httpSecurity.build();
}
}
JWT
와 관련된 설정 추가JwtTokenProvider
:
이 클래스는 JWT 토큰 생성, 유효성 검증 등을 담당하는 유틸리티 클래스이다.
JwtTokenProvider
를 필터 안에 넣음으로 HTTP 요청에서 JWT의 유효성을 검사하고, 이를 기반으로 사용자를 인증할 수 있게 해준다.
JwtAuthenticationFilter
:
이 필터를 UsernamePasswordAuthenticationFilter
앞에 추가하여, HTTP 요청이 도착하면 먼저 JWT를 추출하고 유효성을 검사한다.
유효한 경우 UsernamePasswordAuthenticationFilter
가 실행되어 기본 인증을 수행합니다.
🤔 필터를 앞에 추가하면?
코드에서JwtAuthenticationFilter
는Spring Security
의 기본 인증 필터보다 우선 실행된다. 이는 JWT를 사용하여 보다 먼저 인증을 처리할 수 있어서, 추가적인 보안 검사나 사용자 정의 로직을 수행할 수 있게 한다.
JWT
JwtTokenProvider
@Component
@RequiredArgsConstructor
public class JwtTokenProvider {
@Value("${jwt.secret}")
private String secretKey; // 이미 Base64로 인코딩된 시크릿키
@Value("${jwt.token-validity-in-seconds}")
private long accessTokenValiditySeconds;
@Value("${jwt.refresh-token-validity-in-seconds}")
private long refreshTokenValiditySeconds;
// 주어진 Authentication 객체를 기반으로 JWT 토큰을 생성한다.
public String createToken(Authentication authentication) {
return generateToken(authentication, accessTokenValiditySeconds);
}
public String createRefreshToken(Authentication authentication) {
return generateToken(authentication, refreshTokenValiditySeconds);
}
// JWT 토큰 생성
private String generateToken(Authentication authentication, long validitySeconds) {
CustomUserDetails userDetails = (CustomUserDetails) authentication.getPrincipal();
Claims claims = Jwts.claims().setSubject(userDetails.getEmail());
Date now = new Date();
Date validity = new Date(now.getTime() + validitySeconds * 1000);
String jti = UUID.randomUUID().toString();
return Jwts.builder()
.setClaims(claims)
.setId(jti)
.setIssuedAt(now)
.setExpiration(validity)
.signWith(SignatureAlgorithm.HS256, secretKey)
.compact();
}
// JWT 토큰의 유효성 검사
// 유효한 토큰일 경우 true, 그렇지 않으면 false 리턴
public boolean validateToken(String token) {
try {
Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token);
return true;
} catch (Exception e) {
return false;
}
}
// JWT 토큰에서 사용자 이름을 추출
public String getEmail(String token) {
return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody().getSubject();
}
// HttpServletRequest에서 Authorization 헤더에서 JWT 토큰을 추출
public String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7); // Bearer 토큰을 제외한 JWT 토큰 리턴
}
return null;
}
}
@Value
: 이 어노테이션은 Spring 프레임워크에서 제공하는 기능으로, application.yml
과 같은 설정 파일에서 값을 가져오는데 활용된다. 여기서는 yml
에서 설정했던 시크릿 키와 유효 기간을 가져와 사용한다.
createToken
, createRefreshToken
: 주어진 Authentication
객체를 기반으로 JWT 토큰을 생성한다. 사용자의 주요 정보를 클레임(Claims)으로 설정하고, 토큰의 발급일과 만료일을 설정하여 서명한다.
generateToken
: 토큰 생성을 담당하는 내부 메서드로, 특정 유효 기간을 가진 JWT를 생성한다.
validateToken
: 주어진 JWT 토큰의 유효성을 검사한다. 서명 키를 사용하여 토큰의 유효성을 확인하고, 만료 여부도 함께 체크한다.
getEmail
: JWT 토큰에서 추출한 사용자 이메일을 반환한다. 이 프로젝트에서는 아이디 대신 이메일을 사용하기 때문에, 반환된 이메일은 인증 이후 사용자 정보를 확인할 때 사용된다.
resolveToken
: HTTP 요청의 Authorization
헤더에서 JWT 토큰을 추출한다. 이 메서드는 클라이언트에서 서버로 JWT를 전송하고, 서버에서 JWT를 추출하여 인증을 처리할 때 사용된다.
JwtTokenProvider
클래스는 @Component
어노테이션을 통해 스프링의 빈으로 등록되어 있어, 다른 컴포넌트에서 주입하여 사용할 수 있다. 주로 Spring Security
와 통합되어 인증된 사용자의 토큰을 생성하고 관리하는 데 활용된다. 이를 통해 보안 강화와 함께 효율적인 사용자 인증을 구현할 수 있다.
JwtAuthenticationFilter
// JWT 토큰을 사용해 인증을 처리하는 필터
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtTokenProvider;
private final CustomUserDetailsService userDetailsService;
// HTTP 요청을 필터링하여 JWT 토큰을 검증하고, 사용자를 인증한다.
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
// 요청에서 JWT 토큰 추출
String token = jwtTokenProvider.resolveToken(request);
// 추출한 토큰의 유효성 검증
if (token != null && jwtTokenProvider.validateToken(token)) {
// 통과하면 토큰에서 이메일을 가져온다.
String email = jwtTokenProvider.getEmail(token);
// 이메일을 사용해 사용자 정보를 로드
UserDetails userDetails = userDetailsService.loadUserByUsername(email);
// 사용자 정보와 권한을 사용해 인증 객체를 생성
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
// 인증 객체에 요청의 세부 정보를 추가
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
// SecurityContextHolder를 사용하여 인증 객체를 설정
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
}
// 다음 필터 체인으로 제어를 넘긴다.
chain.doFilter(request, response);
}
}
JwtAuthenticationFilter
란?JwtAuthenticationFilter
클래스는 Spring Security
의 OncePerRequestFilter
를 확장하여 JWT 토큰을 사용해 인증을 처리하는 필터이다. 클라이언트가 요청을 보낼 때마다 JWT 토큰을 검사하고, 유효한 경우 해당 사용자를 Spring Security의 인증 매커니즘에 따라 인증한다. 이를 통해 JWT를 통한 보안 인증을 구현하고, 안전한 애플리케이션 접근을 보장할 수 있다.
💡
OncePerRequestFilter
란?
OncePerRequestFilter
는 스프링 프레임워크에서 제공하는 추상 클래스로, 모든 HTTP 요청에 대해 한 번만 실행되도록 보장하여 필터의 중복 실행을 방지한다. 이를 통해 필터의 실행 순서와 중복 호출에 대한 관리를 간편하게 할 수 있다.
JWT 토큰 검증: 클라이언트가 HTTP 요청을 보낼 때 헤더에 포함된 JWT 토큰을 추출하고, 이 토큰의 유효성을 검사한다.
사용자 인증: 유효한 JWT 토큰에서 추출한 정보(일반적으로 사용자 이메일)를 기반으로, 해당 사용자의 상세 정보를 데이터베이스나 다른 저장소에서 조회하여 사용자를 인증한다.
Spring Security에 인증 설정: 검증된 사용자 정보를 사용하여 UsernamePasswordAuthenticationToken
객체를 생성하고, 이를 SecurityContextHolder
에 설정하여 현재 사용자로 인식하게 한다.
필터 체인 관리: OncePerRequestFilter
를 상속하여 구현했기 때문에, 각 HTTP 요청에 대해 한 번만 실행되도록 보장합니다. 이를 통해 필터의 중복 실행을 방지하고 실행 순서를 명확히 관리할 수 있다.
제어 넘기기: chain.doFilter(request, response)
를 호출하여 다음 필터 체인으로 제어를 넘긴다.
이와 같은 역할을 통해 JwtAuthenticationFilter
는 JWT를 통한 안전한 인증 과정을 구현하며, Spring Security와 통합하여 안전한 웹 애플리케이션을 구축하는 데 기여한다.
Service
AuthService
@Service
@RequiredArgsConstructor
public class AuthService {
private final AuthenticationManager authenticationManager;
private final JwtTokenProvider jwtTokenProvider;
public HttpHeaders login(String email, String password) {
try {
// 인증 수행
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(email, password));
// 토큰 생성
String accessToken = jwtTokenProvider.createToken(authentication);
String refreshToken = jwtTokenProvider.createRefreshToken(authentication);
// 헤더 설정
HttpHeaders headers = new HttpHeaders();
headers.add(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken);
headers.add("Refresh-Token", refreshToken);
return headers;
} catch (AuthenticationException e) {
// 인증 실패 시 예외 처리
throw new AuthenticationException("Authentication failed: " + e.getMessage()) {};
}
}
}
AuthService
란?이 프로젝트에선 기본적인 CRUD를 다루는 User
와 인증 관련 기능을 다루는 Auth
로 클래스를 나누었다. 즉, AuthService
란 사용자 인증 및 JWT 토큰 관리를 담당하는 서비스 클래스이다.
login
메서드authenticate(email, password)
: 사용자의 이메일과 비밀번호를 받아 인증 매니저(AuthenticationManager
)를 통해 인증을 시도하고, 인증 객체(Authentication
)를 반환한다.
createToken
, createRefreshToken
: 인증 객체를 기반으로 JWT 토큰을 생성한다. JwtTokenProvider
의 메서드를 사용한다.
HttpHeaders
: 헤더를 설정하는 과정으로, accessToken
과 refreshToken
을 헤더에 주입 후 반환한다.
이 클래스는 생성자 주입을 통해 필요한 AuthenticationManager
, JwtTokenProvider
를 주입받는다. 이를 통해 사용자 인증과 JWT 관리를 통합적으로 처리하는 역할을 한다.
CustomUserDetailsService
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
@Override // 이메일을 기준으로 사용자를 로드하는 메서드
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
// 이메일로 사용자 조회
User user = userRepository.findByEmail(email)
.orElseThrow(() -> new UsernameNotFoundException("해당 이메일을 가진 사용자를 찾을 수 없습니다: " + email));
// 조회된 사용자 정보를 기반으로 CustomUserDetails 객체 생성 후 반환
return new CustomUserDetails(
user.getUsername(),
user.getPassword(),
user.getEmail(),
user.isAccountNonExpired(), // 계정 만료 여부
user.isAccountNonLocked(), // 계정 잠김 여부
user.isCredentialsNonExpired(), // 자격 증명 만료 여부
user.isEnabled(), // 계정 활성화 여부
Collections.emptyList()
);
}
}
CustomUserDetailsService
의 역할CustomUserDetailsService
클래스는 UserDetailsService
인터페이스를 구현하여 사용자 상세 정보를 로드하는 서비스이다. 주로 Spring Security와 함께 사용되며, 다음과 같은 역할을 수행한다.
사용자 조회: loadUserByUsername
메서드를 오버라이드하여 사용자의 이메일을 기준으로 데이터베이스에서 사용자를 조회한다. UserRepository
를 사용하여 데이터베이스에서 사용자 정보를 가져온다.
CustomUserDetails
생성: 조회된 User
엔티티를 기반으로 CustomUserDetails
객체를 생성하여 반환한다. CustomUserDetails
는 Spring Security의 UserDetails
인터페이스를 구현한 사용자의 세부 정보를 클래스로, 사용자의 인증 상태와 권한 정보를 포함한다.
Exception
처리: findByEmail
메서드에서 사용자를 찾지 못한 경우 UsernameNotFoundException
을 던진다. 이는 Spring Security에서 사용자를 찾을 수 없을 때 발생하는 예외이다.
즉, CustomUserDetailsService
는 주로 Spring Security
설정에서 사용자의 인증을 처리하거나, 권한을 확인하는 데 활용된다. 이를 통해 사용자 인증과 권한 부여 과정을 효율적으로 처리할 수 있다.
Controller
AuthController
@RestController
@RequestMapping("/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthService authService;
@PostMapping("/login")
public ResponseEntity<Response> login(@RequestBody AuthRequest authRequest) {
try {
HttpHeaders headers = authService.login(authRequest.getEmail(), authRequest.getPassword());
Response response = new Response(HttpStatus.OK.value(), "로그인에 성공했습니다.");
return ResponseEntity.ok()
.headers(headers)
.body(response);
} catch (AuthenticationException e) {
Response errorResponse = new Response(HttpStatus.UNAUTHORIZED.value(), "이메일 주소나 비밀번호가 올바르지 않습니다.");
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(errorResponse);
}
}
@Getter
public static class AuthRequest {
private String email;
private String password;
}
@Getter
public static class Response {
private int statusCode;
private String message;
public Response(int statusCode, String message) {
this.statusCode = statusCode;
this.message = message;
}
}
}
AuthController
란?AuthController
클래스는 각 요청에 대해 AuthService
를 사용하여 로그인을 처리하고, 인증된 사용자에게 JWT 토큰을 발급하여 응답하는 역할을 한다.
@RequestMapping("/auth")
: /auth
엔드포인트를 처리하도록 붙여주었다. 참고로 UserController
는 /user
로 설정하고 설정 파일도 조금 수정해주었다.
@PostMapping("/login")
: POST 메서드로 들어오는 /auth/login
요청을 처리한다. AuthRequest
객체를 받아 사용자의 이메일과 비밀번호를 통해 로그인을 시도한다.
AuthService
의 login
메서드를 통해 인증을 수행하고, 성공하면 발급된 JWT 액세스 토큰과 리프레시 토큰을 HTTP 응답의 헤더에 추가하여 클라이언트에게 반환한다.AuthenticationException
이 발생하면 인증 실패 상태 코드와 메시지를 담은 오류 응답을 반환합니다.AuthRequest
클래스: 클라이언트로부터 전달받은 로그인 요청을 담는 DTO 클래스이다. 이메일과 비밀번호 필드를 포함한다.
Response
클래스: 응답 메시지를 담는 DTO 클래스로, HTTP 상태 코드와 메시지를 포함한다.
이 컨트롤러는 스프링 프레임워크의 @RestController
와 @RequestMapping
을 사용하여 HTTP 요청을 받고, ResponseEntity
를 통해 적절한 HTTP 상태 코드와 함께 JSON 형태로 응답을 반환한다. 이를 통해 사용자의 로그인 요청을 처리하고, 보안을 강화하는 JWT 기반 인증 시스템을 구현한다.
Test
지금까지 코드를 전부 작성했다면 정상적으로 로그인 처리가 될 것이다.
if
로그인 성공로그인을 성공했을 때의 코드이다. Body에 statusCode
와 message
가 정상적으로 전달되었고, Header에는 Authorization
(액세스 토큰)과 Refresh-Token
이 잘 발급되어 전달된 것을 확인할 수 있다.
if
로그인 실패잘못된 이메일이나 비밀번호를 입력하면 message
에 "이메일 주소나 비밀번호가 올바르지 않습니다."라는 문구가 뜬다.
다음 게시물에서는 로그아웃 처리를 구현해 보려고 한다. 또한 추후 이메일 인증도 추가해서 더욱 현업에 가까운 인증 처리를 구현할 것이다.