Spring Boot JWT 인증과 인가 구현하기 Spring Boot JWT Authentication and Authorization
Spring Boot JWT Authentication and Authorization
Session-based authentication stores user state on the server. This works for monolithic applications, but becomes problematic when scaling horizontally or building microservices. JWT (JSON Web Token) solves this by encoding user information in a signed token that clients send with each request.
1. Why JWT?
JWT offers several advantages over session-based authentication:
- Stateless: No server-side session storage required
- Scalable: Any server can validate the token without shared state
- Cross-domain: Works seamlessly across different services
- Mobile-friendly: Easy to store and send from mobile apps
A JWT consists of three parts: header, payload, and signature. The server signs the token with a secret key, so it can verify authenticity without database lookups.
2. Project Setup
Spring Security 6.x requires Spring Boot 3.x. Add the required dependencies:
plugins {
id 'java'
id 'org.springframework.boot' version '3.2.1'
id 'io.spring.dependency-management' version '1.1.4'
}
group = 'com.example'
version = '0.0.1-SNAPSHOT'
java {
sourceCompatibility = '17'
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
// JWT
implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.3'
// Database
runtimeOnly 'com.h2database:h2'
// Lombok
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
}
The jjwt library (version 0.12.x) requires all three modules: api, impl, and jackson.
3. JWT Configuration
Configure the JWT secret key and expiration times:
spring:
datasource:
url: jdbc:h2:mem:testdb
driver-class-name: org.h2.Driver
username: sa
password:
jpa:
hibernate:
ddl-auto: create-drop
show-sql: true
h2:
console:
enabled: true
jwt:
secret: your-256-bit-secret-key-here-must-be-at-least-256-bits-long
expiration: 3600000 # 1 hour in milliseconds
refresh-expiration: 604800000 # 7 days in milliseconds
The secret key must be at least 256 bits (32 characters) for HS256 algorithm. In production, use environment variables.
4. User Entity
The User entity implements UserDetails directly for seamless Spring Security integration:
@Entity
@Table(name = "users")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class User implements UserDetails {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false)
private String email;
@Column(nullable = false)
private String password;
private String name;
@Enumerated(EnumType.STRING)
private Role role;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return List.of(new SimpleGrantedAuthority("ROLE_" + role.name()));
}
@Override
public String getUsername() {
return email;
}
@Override
public boolean isAccountNonExpired() { return true; }
@Override
public boolean isAccountNonLocked() { return true; }
@Override
public boolean isCredentialsNonExpired() { return true; }
@Override
public boolean isEnabled() { return true; }
}
public enum Role {
USER, ADMIN
}
5. JWT Utility Class
The JwtUtil class handles all JWT operations:
@Component
public class JwtUtil {
@Value("${jwt.secret}")
private String secret;
@Value("${jwt.expiration}")
private Long expiration;
@Value("${jwt.refresh-expiration}")
private Long refreshExpiration;
private SecretKey getSigningKey() {
byte[] keyBytes = secret.getBytes(StandardCharsets.UTF_8);
return Keys.hmacShaKeyFor(keyBytes);
}
public String extractUsername(String token) {
return extractClaim(token, Claims::getSubject);
}
public Date extractExpiration(String token) {
return extractClaim(token, Claims::getExpiration);
}
public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
final Claims claims = extractAllClaims(token);
return claimsResolver.apply(claims);
}
private Claims extractAllClaims(String token) {
return Jwts.parser()
.verifyWith(getSigningKey())
.build()
.parseSignedClaims(token)
.getPayload();
}
private Boolean isTokenExpired(String token) {
return extractExpiration(token).before(new Date());
}
public String generateToken(UserDetails userDetails) {
Map<String, Object> claims = new HashMap<>();
claims.put("role", userDetails.getAuthorities().iterator().next().getAuthority());
return createToken(claims, userDetails.getUsername(), expiration);
}
public String generateRefreshToken(UserDetails userDetails) {
return createToken(new HashMap<>(), userDetails.getUsername(), refreshExpiration);
}
private String createToken(Map<String, Object> claims, String subject, Long expirationTime) {
return Jwts.builder()
.claims(claims)
.subject(subject)
.issuedAt(new Date(System.currentTimeMillis()))
.expiration(new Date(System.currentTimeMillis() + expirationTime))
.signWith(getSigningKey())
.compact();
}
public Boolean validateToken(String token, UserDetails userDetails) {
final String username = extractUsername(token);
return (username.equals(userDetails.getUsername()) && !isTokenExpired(token));
}
}
Key points:
Keys.hmacShaKeyFor()creates a secure key from the secret string- Access token includes the user’s role as a custom claim
- Refresh tokens have longer expiration with minimal claims
6. UserDetailsService Implementation
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return userRepository.findByEmail(username)
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));
}
}
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);
Boolean existsByEmail(String email);
}
7. JWT Authentication Filter
The filter intercepts requests, extracts the JWT, and sets the security context:
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
private final UserDetailsService userDetailsService;
@Override
protected void doFilterInternal(
@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain
) throws ServletException, IOException {
final String authHeader = request.getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
final String jwt = authHeader.substring(7);
final String username;
try {
username = jwtUtil.extractUsername(jwt);
} catch (Exception e) {
filterChain.doFilter(request, response);
return;
}
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
if (jwtUtil.validateToken(jwt, userDetails)) {
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
userDetails,
null,
userDetails.getAuthorities()
);
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken);
}
}
filterChain.doFilter(request, response);
}
}
The filter extends OncePerRequestFilter to guarantee single execution per request.
8. Security Configuration
Spring Security 6.x uses component-based configuration with SecurityFilterChain:
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
private final UserDetailsService userDetailsService;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/h2-console/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.authenticationProvider(authenticationProvider())
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.headers(headers -> headers
.frameOptions(frameOptions -> frameOptions.sameOrigin())
);
return http.build();
}
@Bean
public AuthenticationProvider authenticationProvider() {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
provider.setUserDetailsService(userDetailsService);
provider.setPasswordEncoder(passwordEncoder());
return provider;
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
Key configuration:
- CSRF disabled (stateless API doesn’t need it)
- Session management set to STATELESS
- JWT filter added before
UsernamePasswordAuthenticationFilter @EnableMethodSecurityenables@PreAuthorizeannotations
9. DTOs
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class RegisterRequest {
private String email;
private String password;
private String name;
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class LoginRequest {
private String email;
private String password;
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AuthResponse {
private String accessToken;
private String refreshToken;
private String tokenType;
private Long expiresIn;
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class RefreshTokenRequest {
private String refreshToken;
}
10. Authentication Service
@Service
@RequiredArgsConstructor
public class AuthService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final JwtUtil jwtUtil;
private final AuthenticationManager authenticationManager;
public AuthResponse register(RegisterRequest request) {
if (userRepository.existsByEmail(request.getEmail())) {
throw new RuntimeException("Email already exists");
}
User user = User.builder()
.email(request.getEmail())
.password(passwordEncoder.encode(request.getPassword()))
.name(request.getName())
.role(Role.USER)
.build();
userRepository.save(user);
String accessToken = jwtUtil.generateToken(user);
String refreshToken = jwtUtil.generateRefreshToken(user);
return AuthResponse.builder()
.accessToken(accessToken)
.refreshToken(refreshToken)
.tokenType("Bearer")
.expiresIn(3600L)
.build();
}
public AuthResponse login(LoginRequest request) {
authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
request.getEmail(),
request.getPassword()
)
);
User user = userRepository.findByEmail(request.getEmail())
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
String accessToken = jwtUtil.generateToken(user);
String refreshToken = jwtUtil.generateRefreshToken(user);
return AuthResponse.builder()
.accessToken(accessToken)
.refreshToken(refreshToken)
.tokenType("Bearer")
.expiresIn(3600L)
.build();
}
public AuthResponse refresh(RefreshTokenRequest request) {
String refreshToken = request.getRefreshToken();
String username = jwtUtil.extractUsername(refreshToken);
User user = userRepository.findByEmail(username)
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
if (!jwtUtil.validateToken(refreshToken, user)) {
throw new RuntimeException("Invalid refresh token");
}
String newAccessToken = jwtUtil.generateToken(user);
return AuthResponse.builder()
.accessToken(newAccessToken)
.refreshToken(refreshToken)
.tokenType("Bearer")
.expiresIn(3600L)
.build();
}
}
11. Auth Controller
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthService authService;
@PostMapping("/register")
public ResponseEntity<AuthResponse> register(@RequestBody RegisterRequest request) {
return ResponseEntity.ok(authService.register(request));
}
@PostMapping("/login")
public ResponseEntity<AuthResponse> login(@RequestBody LoginRequest request) {
return ResponseEntity.ok(authService.login(request));
}
@PostMapping("/refresh")
public ResponseEntity<AuthResponse> refresh(@RequestBody RefreshTokenRequest request) {
return ResponseEntity.ok(authService.refresh(request));
}
}
12. Protected Endpoint Example
@RestController
@RequestMapping("/api")
@RequiredArgsConstructor
public class UserController {
@GetMapping("/me")
public ResponseEntity<Map<String, Object>> getCurrentUser(@AuthenticationPrincipal User user) {
return ResponseEntity.ok(Map.of(
"id", user.getId(),
"email", user.getEmail(),
"name", user.getName(),
"role", user.getRole()
));
}
@GetMapping("/admin/users")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<String> adminOnly() {
return ResponseEntity.ok("Admin access granted");
}
}
@AuthenticationPrincipal injects the authenticated User. @PreAuthorize provides method-level security.
13. Testing the API
Register:
curl -X POST http://localhost:8080/api/auth/register \
-H "Content-Type: application/json" \
-d '{"email":"user@example.com","password":"password123","name":"Test User"}'
Login:
curl -X POST http://localhost:8080/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"user@example.com","password":"password123"}'
Access protected endpoint:
curl -X GET http://localhost:8080/api/me \
-H "Authorization: Bearer <your-access-token>"
Refresh token:
curl -X POST http://localhost:8080/api/auth/refresh \
-H "Content-Type: application/json" \
-d '{"refreshToken":"<your-refresh-token>"}'
14. Conclusion
JWT authentication provides a stateless, scalable approach for securing REST APIs. The key components are JwtUtil for token operations, JwtAuthenticationFilter for request interception, and SecurityFilterChain for configuration. For production, use HTTPS, rotate secrets periodically, store refresh tokens in database for revocation capability, and consider token rotation on refresh.
댓글남기기