Code N Solve ๐: Java Spring ํ๋ ์์ํฌ์์์ ์ธ์ฆ ์๋ ์๋ฆฌ์ ๋ฌธ์ ํด๊ฒฐ
Spring ํ๋ ์์ํฌ๋ ์๋ฐ ์ดํ๋ฆฌ์ผ์ด์ ์์ ์ธ์ฆ๊ณผ ์ ๊ทผ ์ ์ด๋ฅผ ์ํ ๊ฐ๋ ฅํ๊ณ ์ ์ฐํ ๋๊ตฌ์ด๋ค.
Spring Security๋ฅผ ์ด์ฉํ ์ธ์ฆ ๋ฉ์ปค๋์ฆ์ ๋์ ์๋ฆฌ์ ์ผ๋ฐ์ ์ผ๋ก ๋ฐ์ํ ์ ์๋ ์ผ๋ฐ์ ์ธ ๋ฌธ์ , ๊ทธ๋ฆฌ๊ณ ๊ทธ ํด๊ฒฐ ๋ฐฉ๋ฒ์ ๋ํด ์์๋ณด์.
Spring Security ? ๐ค
- Spring Security๋ Java ๊ธฐ๋ฐ ์ดํ๋ฆฌ์ผ์ด์ ์์ ํ์์ ์ผ๋ก ๊ณ ๋ ค๋์ด์ผ ํ ์์ ์ค ํ๋์ด๋ค.
- ์ฌ๋ฐ๋ฅธ ์ฌ์ฉ์ ์๋ณ๊ณผ ์ ๊ทผ ์ ์ด๋ฅผ ํตํด ์ดํ๋ฆฌ์ผ์ด์ ์ ์ ๋ขฐ์ฑ์ ๋์ด๊ณ ๋ณด์์ ๊ฐํํ ์ ์๋ค.
- ํนํ ์ฌ์ฉ์ ๋ฐ์ดํฐ ๋ณดํธ์ ๊ถํ ๊ด๋ฆฌ ๊ธฐ๋ฅ์ ์ ๊ณตํ๋ Spring Security๋ ๊ฐ๋ ฅํ ๋์ฑ ์ด ๋ ์ ์๋ค.
Spring Security Filter Chain ์ ์ฒด ๋์ ํ๋ฆ
Spring Security์ ํต์ฌ์ Filter Chain์ด๋ค. ํด๋ผ์ด์ธํธ์ HTTP ์์ฒญ์ด ์ค์ ์ปจํธ๋กค๋ฌ์ ๋๋ฌํ๊ธฐ ์ ์, ์ฌ๋ฌ ํํฐ๋ฅผ ์์๋๋ก ํต๊ณผํ๋ฉด์ ์ธ์ฆ๊ณผ ์ธ๊ฐ๊ฐ ์ฒ๋ฆฌ๋๋ค. ๊ฐ ํํฐ๋ ๋จ์ผ ์ฑ ์ ์์น์ ๋ฐ๋ผ ์์ ์ ์ญํ ๋ง ์ํํ๊ณ , ๋ค์ ํํฐ๋ก ์์ฒญ์ ๋๊ธด๋ค.
์ฃผ์ ํํฐ ์ค๋ช
| ํํฐ ์ด๋ฆ | ์ญํ |
|---|---|
SecurityContextPersistenceFilter |
์์ฒญ ์์ ์ ์ธ์
์์ SecurityContext๋ฅผ ๋ณต์ํ๊ณ , ์ข
๋ฃ ์ ์ ์ฅ |
UsernamePasswordAuthenticationFilter |
ํผ ๋ก๊ทธ์ธ POST ์์ฒญ์ ๊ฐ๋ก์ฑ์ด ์ธ์ฆ ์ฒ๋ฆฌ |
BearerTokenAuthenticationFilter |
Authorization ํค๋์ JWT ํ ํฐ์ ์ถ์ถํ์ฌ ์ธ์ฆ ์ฒ๋ฆฌ |
ExceptionTranslationFilter |
AuthenticationException, AccessDeniedException์ ์ ์ ํ HTTP ์๋ต์ผ๋ก ๋ณํ |
FilterSecurityInterceptor |
URL๋ณ ๊ถํ ๊ท์น์ ํ์ธํ์ฌ ์ธ๊ฐ ์ฒ๋ฆฌ |
์ค์ ๋ก Spring Security๋ ์์ญ ๊ฐ์ ํํฐ๋ฅผ ์ ๊ณตํ์ง๋ง, ๋๋ถ๋ถ์ ์ ํ๋ฆฌ์ผ์ด์ ์์๋ ์ 5๊ฐ ํํฐ๊ฐ ํต์ฌ ์ญํ ์ ๋ด๋นํ๋ค.
Spring Authentication ์๋ ์๋ฆฌ
์ธ์ฆ ๊ณผ์
Spring ์ดํ๋ฆฌ์ผ์ด์ ๋ด์์ ์ฌ์ฉ์๊ฐ ์์คํ ์ ์ ๊ทผํ๊ธฐ ์ํด์๋ ์ธ์ฆ ๊ณผ์ ์ ์ฐ์ ๊ฑฐ์ณ์ผ ํ๋ค.
-
์ฌ์ฉ์ ์ ๋ณด ์ ๋ ฅ
- ํด๋ผ์ด์ธํธ๊ฐ ์๋ฒ์ ๋ก๊ทธ์ธ ์์ฒญ์ ๋ณด๋ด๋ฉด, ์ฌ์ฉ์๋ ์ด๋ฉ์ผ๊ณผ ๋น๋ฐ๋ฒํธ์ ๊ฐ์ ์๊ฒฉ ์ฆ๋ช ์ ์ ๋ ฅํ๋ค.
-
์ธ์ฆ ํ๋ก์ธ์ค ์์
- ์๋ฒ์์๋ ์ ๋ ฅ๋ ์๊ฒฉ ์ฆ๋ช ์ ๋ฐํ์ผ๋ก ์ฌ์ฉ์๋ฅผ ์ธ์ฆํ๋ ค๊ณ ์๋ํ๋ค.
- ์ด๋
AuthenticationManager๋ฅผ ํตํด ์ฌ์ฉ์์ ์๊ฒฉ ์ฆ๋ช ์ด ์ ํจํ์ง ํ์ธํ๋ค. - Spring Security๋ ์ด ๊ณผ์ ์ ํตํด ์ฌ์ฉ์ ์ ๋ณด๋ฅผ ๋ฐ์ดํฐ๋ฒ ์ด์ค๋ ๋ค๋ฅธ ์ ์ฅ์์์ ์กฐํํ์ฌ ์ ํจ์ฑ์ ๊ฒ์ฆํ๋ค.
-
Authentication authentication = authenticationManager.authenticate( new UsernamePasswordAuthenticationToken(user.getEmail(), user.getPassword()));
-
๊ถํ ๋ถ์ฌ
- ์ธ์ฆ์ด ์ฑ๊ณตํ๋ฉด, ์ฌ์ฉ์๋ ๋ฐ์ดํฐ๋ฒ ์ด์ค์ ์ ์ฅ๋ ์ญํ (Role)์ ๋ฐ๋ผ ์ ์ ํ ๊ถํ์ ๋ถ์ฌ๋ฐ๋๋ค.
- ์ดํ ์ ํ๋ฆฌ์ผ์ด์ ์ ๋ค๋ฅธ ๋ถ๋ถ์์ ๊ถํ์ด ๊ฒ์ฆ๋์ด ์ฌ์ฉ์๊ฐ ํน์ ์์ ์ ์ํํ ์ ์๋์ง ๊ฒฐ์ ๋๋ค.
-
SecurityContextHolder
- ์ธ์ฆ์ด ์ฑ๊ณตํ๋ฉด, Spring Security๋
SecurityContextHolder๋ฅผ ์ฌ์ฉํ์ฌ ์ธ์ฆ ์ ๋ณด๋ฅผ ๊ด๋ฆฌํ๋ค.SecurityContextHolder๋ ํ์ฌ ์ธ์ฆ๋ ์ฌ์ฉ์์ ์ธ๋ถ ์ ๋ณด๋ฅผ ์ ์ฅํ๋ ์ปจํ ์คํธ์ด๋ค.-
SecurityContextHolder.getContext().setAuthentication(authentication);
- ์ธ์ฆ์ด ์ฑ๊ณตํ๋ฉด, Spring Security๋
์ธ์ฆ๊ณผ ์ธ๊ฐ์ ์ฐจ์ด 1? ๐ค
-
์ธ์ฆ(Authentication): '๋๊ตฌ์ธ์ง'๋ฅผ ์๋ ํ๋ก์ธ์ค๋ฅผ ์๋ฏธ
-
์ธ๊ฐ(Authorization): '๋ฌด์์ ํ ์ ์๋์ง'๋ฅผ ๊ฒฐ์
-
๋ ์ฉ์ด๋ ์ข ์ข ํผ์ฉ๋์ง๋ง ๋ช ํํ ๊ตฌ๋ถํ ํ์๊ฐ ์๋ค.
-
์ ํ๋ฆฌ์ผ์ด์ ์ค๊ณ ์ ๋ ๊ฐ๋ ์ ์ ํํ ์ดํดํ๊ณ ๊ตฌํํด์ผ ํ๋ค.
Spring Security 6.x ์ค์ ๋ฐฉ๋ฒ (Lambda DSL)
Spring Boot 3.x๋ถํฐ๋ Spring Security 6.x๊ฐ ๊ธฐ๋ณธ์ผ๋ก ์ฌ์ฉ๋๋ค. ๊ธฐ์กด์ ๋๋ฆฌ ์ฌ์ฉ๋๋ WebSecurityConfigurerAdapter๋ deprecated ์ฒ๋ฆฌ๋์ด ๋ ์ด์ ์ฌ์ฉ์ ๊ถ์ฅํ์ง ์๋๋ค. ๋์ SecurityFilterChain์ Bean์ผ๋ก ์ง์ ๋ฑ๋กํ๋ ๋ฐฉ์๊ณผ Lambda DSL์ ์ฌ์ฉํด์ผ ํ๋ค.
๊ธฐ์กด ๋ฐฉ์ (Spring Security 5.x, deprecated)
// ๋ ์ด์ ์ฌ์ฉํ์ง ๋ง ๊ฒ
@Configuration
@EnableWebSecurity
public class OldSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/public/**").permitAll()
.anyRequest().authenticated()
.and()
.formLogin()
.loginPage("/login")
.permitAll();
}
}์๋ก์ด ๋ฐฉ์ (Spring Security 6.x, Lambda DSL)
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/public/**", "/api/auth/**").permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS) // JWT ์ฌ์ฉ ์
)
.csrf(csrf -> csrf.disable()) // REST API๋ CSRF ๋นํ์ฑํ
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public AuthenticationManager authenticationManager(
AuthenticationConfiguration authenticationConfiguration) throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}Lambda DSL ๋ฐฉ์์ ํต์ฌ ๋ณํ์ ์ ๋ค์๊ณผ ๊ฐ๋ค.
antMatchersโrequestMatchers๋ก ๋ณ๊ฒฝ.and()์ฒด์ด๋ ๋์ ๊ฐ ์ค์ ์ ๋๋ค ๋ธ๋ก์ผ๋ก ๋ ๋ฆฝ์ ์ผ๋ก ์์ฑhttp.build()๋ฅผ ๋ช ์์ ์ผ๋ก ํธ์ถํ์ฌSecurityFilterChain๋ฐํ
๋น๋ฐ๋ฒํธ ์ธ์ฝ๋ฉ: BCryptPasswordEncoder
ํ๋ฌธ ๋น๋ฐ๋ฒํธ๋ฅผ ๊ทธ๋๋ก ๋ฐ์ดํฐ๋ฒ ์ด์ค์ ์ ์ฅํ๋ ๊ฒ์ ์ฌ๊ฐํ ๋ณด์ ์ทจ์ฝ์ ์ด๋ค. Spring Security๋ PasswordEncoder ์ธํฐํ์ด์ค๋ฅผ ํตํด ๋ค์ํ ํด์ ์๊ณ ๋ฆฌ์ฆ์ ์ง์ํ๋ฉฐ, ์ค๋ฌด์์๋ BCrypt๊ฐ ๊ฐ์ฅ ๋๋ฆฌ ์ฌ์ฉ๋๋ค.
BCrypt๋ ๋จ๋ฐฉํฅ ํด์ ํจ์๋ก, ๊ฐ์ ๋น๋ฐ๋ฒํธ๋ฅผ ์ธ์ฝ๋ฉํด๋ ๋งค๋ฒ ๋ค๋ฅธ ํด์๊ฐ์ด ์์ฑ๋๋ค. ๋ด๋ถ์ ์ผ๋ก ๋๋ค ์ํธ(Salt)๋ฅผ ํฌํจํ๊ธฐ ๋๋ฌธ์ ๋ ์ธ๋ณด์ฐ ํ ์ด๋ธ ๊ณต๊ฒฉ์ ๊ฐํ๋ค.
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
// ํ์๊ฐ์
: ๋น๋ฐ๋ฒํธ ํด์ฑ ํ ์ ์ฅ
public User register(RegisterRequest request) {
if (userRepository.existsByEmail(request.getEmail())) {
throw new IllegalArgumentException("์ด๋ฏธ ์กด์ฌํ๋ ์ด๋ฉ์ผ์
๋๋ค.");
}
User user = User.builder()
.email(request.getEmail())
.password(passwordEncoder.encode(request.getPassword())) // ํด์ฑ
.role(Role.USER)
.build();
return userRepository.save(user);
}
// ๋ก๊ทธ์ธ: ์
๋ ฅ๊ฐ๊ณผ ํด์๊ฐ ๋น๊ต
public boolean verifyPassword(String rawPassword, String encodedPassword) {
return passwordEncoder.matches(rawPassword, encodedPassword);
// matches() ๋ด๋ถ์์ ์ํธ ์ถ์ถ ํ ์ฌํด์ฑํ์ฌ ๋น๊ต
}
}BCryptPasswordEncoder์ ์์ฑ์์๋ strength ํ๋ผ๋ฏธํฐ(๊ธฐ๋ณธ๊ฐ 10)๋ฅผ ์ ๋ฌํ ์ ์๋ค. ๊ฐ์ด ํด์๋ก ํด์ฑ์ ๊ฑธ๋ฆฌ๋ ์๊ฐ์ด ๋์ด๋ ๋ธ๋ฃจํธํฌ์ค ๊ณต๊ฒฉ์ ๋ ๊ฐํด์ง์ง๋ง, ๋ก๊ทธ์ธ ์ฒ๋ฆฌ ์ฑ๋ฅ์ ์ ํ๋๋ค. ์ค๋ฌด์์๋ 10~12๊ฐ ์ ์ ํ๋ค.
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(12); // strength 12
}JWT (JSON Web Token) ๊ธฐ๋ฐ ์ธ์ฆ
JWT๋?
JWT๋ ๋น์ฌ์ ๊ฐ์ ์ ๋ณด๋ฅผ JSON ํํ๋ก ์์ ํ๊ฒ ์ ๋ฌํ๊ธฐ ์ํ ๊ฐ๋ฐฉํ ํ์ค(RFC 7519)์ด๋ค. ์ธ์ ๊ธฐ๋ฐ ์ธ์ฆ๊ณผ ๋ฌ๋ฆฌ ์๋ฒ๊ฐ ์ํ๋ฅผ ์ ์ฅํ์ง ์์๋ ๋๋ฏ๋ก, ๋ง์ดํฌ๋ก์๋น์ค ์ํคํ ์ฒ๋ ์ํ ํ์ฅ์ด ํ์ํ ํ๊ฒฝ์์ ํนํ ์ ์ฉํ๋ค.
JWT ๊ตฌ์กฐ
JWT๋ ์ (.)์ผ๋ก ๊ตฌ๋ถ๋ ์ธ ๋ถ๋ถ์ผ๋ก ๊ตฌ์ฑ๋๋ค.
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyQGV4YW1wbGUuY29tIiwicm9sZXMiOlsiUk9MRV9VU0VSIl0sImlhdCI6MTcwMDAwMDAwMCwiZXhwIjoxNzAwMDA3MjAwfQ.abc123signature| ๊ตฌ์ฑ ์์ | ์ค๋ช | ์์ |
|---|---|---|
| Header | ์๊ณ ๋ฆฌ์ฆ๊ณผ ํ ํฐ ํ์ | {"alg": "HS256", "typ": "JWT"} |
| Payload | ํด๋ ์(์ฌ์ฉ์ ์ ๋ณด, ๋ง๋ฃ ์๊ฐ ๋ฑ) | {"sub": "user@example.com", "exp": 1700007200} |
| Signature | Header + Payload๋ฅผ ๋น๋ฐํค๋ก ์๋ช | HMACSHA256(base64(header) + "." + base64(payload), secret) |
Header์ Payload๋ Base64URL๋ก ์ธ์ฝ๋ฉ๋์ด ์์ด ๋๊ตฌ๋ ๋์ฝ๋ฉํ ์ ์๋ค. ๋ฐ๋ผ์ Payload์ ๋น๋ฐ๋ฒํธ๋ ๋ฏผ๊ฐํ ์ ๋ณด๋ฅผ ์ ๋ ๋ด์ง ๋ง์์ผ ํ๋ค. Signature๋ ์๋ฒ๋ง ์๊ณ ์๋ ๋น๋ฐํค๋ก ์์ฑ๋๋ฏ๋ก, ์๋ณ์กฐ ์ฌ๋ถ๋ฅผ ๊ฒ์ฆํ๋ ๋ฐ ์ฌ์ฉ๋๋ค.
์์กด์ฑ ์ถ๊ฐ
<!-- pom.xml -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.3</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.3</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.3</version>
<scope>runtime</scope>
</dependency>JWT ์ ํธ๋ฆฌํฐ ํด๋์ค
@Component
public class JwtTokenProvider {
@Value("${jwt.secret}")
private String secretKey;
@Value("${jwt.access-token-validity}")
private long accessTokenValidityMs; // ์: 3600000 (1์๊ฐ)
@Value("${jwt.refresh-token-validity}")
private long refreshTokenValidityMs; // ์: 604800000 (7์ผ)
private SecretKey getSigningKey() {
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
return Keys.hmacShaKeyFor(keyBytes);
}
// Access Token ๋ฐ๊ธ
public String generateAccessToken(UserDetails userDetails) {
return generateToken(userDetails, accessTokenValidityMs);
}
// Refresh Token ๋ฐ๊ธ
public String generateRefreshToken(UserDetails userDetails) {
return generateToken(userDetails, refreshTokenValidityMs);
}
private String generateToken(UserDetails userDetails, long validityMs) {
Map<String, Object> claims = new HashMap<>();
claims.put("roles", userDetails.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.toList()));
return Jwts.builder()
.claims(claims)
.subject(userDetails.getUsername())
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + validityMs))
.signWith(getSigningKey())
.compact();
}
// ํ ํฐ์์ ์ฌ์ฉ์๋ช
์ถ์ถ
public String extractUsername(String token) {
return extractClaim(token, Claims::getSubject);
}
// ํ ํฐ ๋ง๋ฃ ์ฌ๋ถ ํ์ธ
public boolean isTokenExpired(String token) {
return extractClaim(token, Claims::getExpiration).before(new Date());
}
// ํ ํฐ ์ ํจ์ฑ ๊ฒ์ฆ
public boolean validateToken(String token, UserDetails userDetails) {
final String username = extractUsername(token);
return username.equals(userDetails.getUsername()) && !isTokenExpired(token);
}
private <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
final Claims claims = Jwts.parser()
.verifyWith(getSigningKey())
.build()
.parseSignedClaims(token)
.getPayload();
return claimsResolver.apply(claims);
}
}JwtAuthenticationFilter ๊ตฌํ
๋งค ์์ฒญ๋ง๋ค Authorization ํค๋์์ JWT๋ฅผ ์ถ์ถํ๊ณ ๊ฒ์ฆํ๋ ํํฐ์ด๋ค. ์ด ํํฐ๋ฅผ UsernamePasswordAuthenticationFilter ์์ ๋ฐฐ์นํ๋ฉด, ํ ํฐ ๊ธฐ๋ฐ ์ธ์ฆ์ด ํผ ๋ก๊ทธ์ธ๋ณด๋ค ๋จผ์ ์ฒ๋ฆฌ๋๋ค.
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtTokenProvider;
private final UserDetailsService userDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
final String authHeader = request.getHeader("Authorization");
// Bearer ํ ํฐ์ด ์์ผ๋ฉด ๋ค์ ํํฐ๋ก ํจ์ค
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
final String jwt = authHeader.substring(7); // "Bearer " ์ดํ์ ํ ํฐ ๋ฌธ์์ด
String username = null;
try {
username = jwtTokenProvider.extractUsername(jwt);
} catch (ExpiredJwtException e) {
// ๋ง๋ฃ๋ ํ ํฐ ์ฒ๋ฆฌ
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.getWriter().write("{\"error\": \"Token expired\"}");
return;
} catch (JwtException e) {
// ์๋ณ์กฐ ๋๋ ์๋ชป๋ ํ ํฐ
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.getWriter().write("{\"error\": \"Invalid token\"}");
return;
}
// SecurityContext์ ์ธ์ฆ ์ ๋ณด๊ฐ ์๋ ๊ฒฝ์ฐ์๋ง ์ฒ๋ฆฌ (์ค๋ณต ์ฒ๋ฆฌ ๋ฐฉ์ง)
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
if (jwtTokenProvider.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);
}
}ํ ํฐ ๋ฐ๊ธ ๋ฐ ๊ฐฑ์ ์๋ํฌ์ธํธ
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthenticationManager authenticationManager;
private final UserDetailsService userDetailsService;
private final JwtTokenProvider jwtTokenProvider;
private final RefreshTokenRepository refreshTokenRepository;
// ๋ก๊ทธ์ธ: Access Token + Refresh Token ๋ฐ๊ธ
@PostMapping("/login")
public ResponseEntity<TokenResponse> login(@RequestBody @Valid LoginRequest request) {
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(request.getEmail(), request.getPassword())
);
UserDetails userDetails = (UserDetails) authentication.getPrincipal();
String accessToken = jwtTokenProvider.generateAccessToken(userDetails);
String refreshToken = jwtTokenProvider.generateRefreshToken(userDetails);
// Refresh Token์ DB์ ์ ์ฅ (ํ์ทจ ์ ๋ฌดํจํ ๊ฐ๋ฅ)
refreshTokenRepository.save(RefreshToken.builder()
.token(refreshToken)
.userEmail(userDetails.getUsername())
.expiresAt(LocalDateTime.now().plusDays(7))
.build());
return ResponseEntity.ok(new TokenResponse(accessToken, refreshToken));
}
// Access Token ๊ฐฑ์
@PostMapping("/refresh")
public ResponseEntity<TokenResponse> refresh(@RequestBody RefreshRequest request) {
String refreshToken = request.getRefreshToken();
// DB์์ Refresh Token ๊ฒ์ฆ
RefreshToken savedToken = refreshTokenRepository.findByToken(refreshToken)
.orElseThrow(() -> new IllegalArgumentException("์ ํจํ์ง ์์ Refresh Token์
๋๋ค."));
if (savedToken.getExpiresAt().isBefore(LocalDateTime.now())) {
refreshTokenRepository.delete(savedToken);
throw new IllegalArgumentException("๋ง๋ฃ๋ Refresh Token์
๋๋ค. ์ฌ๋ก๊ทธ์ธ์ด ํ์ํฉ๋๋ค.");
}
UserDetails userDetails = userDetailsService.loadUserByUsername(savedToken.getUserEmail());
String newAccessToken = jwtTokenProvider.generateAccessToken(userDetails);
return ResponseEntity.ok(new TokenResponse(newAccessToken, refreshToken));
}
// ๋ก๊ทธ์์: Refresh Token ๋ฌดํจํ
@PostMapping("/logout")
public ResponseEntity<Void> logout(@RequestBody RefreshRequest request) {
refreshTokenRepository.deleteByToken(request.getRefreshToken());
return ResponseEntity.noContent().build();
}
}application.yml ์ค์ :
jwt:
secret: your-256-bit-base64-encoded-secret-key-here
access-token-validity: 3600000 # 1์๊ฐ (๋ฐ๋ฆฌ์ด)
refresh-token-validity: 604800000 # 7์ผ (๋ฐ๋ฆฌ์ด)OAuth2 ์์ ๋ก๊ทธ์ธ ์ฐ๋
OAuth2๋?
OAuth2๋ ์ฌ์ฉ์๊ฐ ์์ ์ ์๊ฒฉ ์ฆ๋ช
์ ์ง์ ์ ๊ณตํ์ง ์๊ณ , ๊ตฌ๊ธยท์นด์นด์คยท๋ค์ด๋ฒ ๋ฑ์ ์ธ๋ถ ์๋น์ค๋ฅผ ํตํด ์ธ์ฆํ ์ ์๋๋ก ํด์ฃผ๋ ๊ฐ๋ฐฉํ ํ์ค์ด๋ค. Spring Security๋ spring-security-oauth2-client ๋ชจ๋์ ํตํด ์ด๋ฅผ ๊ฐ๋จํ๊ฒ ์ฐ๋ํ ์ ์๋๋ก ์ง์ํ๋ค.
OAuth2 ์์ ๋ก๊ทธ์ธ ์ ์ฒด ํ๋ฆ
์์กด์ฑ ์ถ๊ฐ
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>application.yml ์ค์
spring:
security:
oauth2:
client:
registration:
google:
client-id: ${GOOGLE_CLIENT_ID}
client-secret: ${GOOGLE_CLIENT_SECRET}
scope:
- email
- profile
kakao:
client-id: ${KAKAO_CLIENT_ID}
client-secret: ${KAKAO_CLIENT_SECRET}
client-authentication-method: client_secret_post
authorization-grant-type: authorization_code
redirect-uri: "{baseUrl}/login/oauth2/code/kakao"
scope:
- profile_nickname
- account_email
provider:
kakao:
authorization-uri: https://kauth.kakao.com/oauth/authorize
token-uri: https://kauth.kakao.com/oauth/token
user-info-uri: https://kapi.kakao.com/v2/user/me
user-name-attribute: idCustomOAuth2UserService ๊ตฌํ
Spring Security๊ฐ OAuth2 ์ ๊ณต์๋ก๋ถํฐ ์ฌ์ฉ์ ์ ๋ณด๋ฅผ ๋ฐ์์จ ํ ํธ์ถํ๋ ์๋น์ค์ด๋ค. ์ด ํด๋์ค์์ ์ฌ์ฉ์๋ฅผ DB์ ์ ์ฅํ๊ฑฐ๋ ๊ธฐ์กด ์ฌ์ฉ์์ ์ฐ๋ํ๋ ๋ก์ง์ ๊ตฌํํ๋ค.
@Service
@RequiredArgsConstructor
public class CustomOAuth2UserService extends DefaultOAuth2UserService {
private final UserRepository userRepository;
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2User oAuth2User = super.loadUser(userRequest);
String registrationId = userRequest.getClientRegistration().getRegistrationId();
OAuth2UserInfo userInfo = extractUserInfo(registrationId, oAuth2User.getAttributes());
User user = userRepository.findByEmail(userInfo.getEmail())
.map(existingUser -> existingUser.updateOAuthInfo(userInfo.getName()))
.orElseGet(() -> User.builder()
.email(userInfo.getEmail())
.name(userInfo.getName())
.provider(registrationId)
.role(Role.USER)
.build());
userRepository.save(user);
return new DefaultOAuth2User(
Collections.singleton(new SimpleGrantedAuthority(user.getRole().name())),
oAuth2User.getAttributes(),
userRequest.getClientRegistration()
.getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName()
);
}
private OAuth2UserInfo extractUserInfo(String provider, Map<String, Object> attributes) {
return switch (provider) {
case "google" -> new GoogleUserInfo(attributes);
case "kakao" -> new KakaoUserInfo(attributes);
default -> throw new OAuth2AuthenticationException("์ง์ํ์ง ์๋ OAuth2 ์ ๊ณต์: " + provider);
};
}
}OAuth2 ์ฑ๊ณต ํธ๋ค๋ฌ (JWT ๋ฐ๊ธ)
์์ ๋ก๊ทธ์ธ ์ฑ๊ณต ํ JWT๋ฅผ ๋ฐ๊ธํ์ฌ ํ๋ก ํธ์๋๋ก ์ ๋ฌํ๋ ํธ๋ค๋ฌ์ด๋ค.
@Component
@RequiredArgsConstructor
public class OAuth2AuthenticationSuccessHandler
extends SimpleUrlAuthenticationSuccessHandler {
private final JwtTokenProvider jwtTokenProvider;
private final UserRepository userRepository;
@Value("${app.oauth2.redirect-uri}")
private String redirectUri;
@Override
public void onAuthenticationSuccess(HttpServletRequest request,
HttpServletResponse response,
Authentication authentication) throws IOException {
OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal();
String email = (String) oAuth2User.getAttributes().get("email");
User user = userRepository.findByEmail(email)
.orElseThrow(() -> new UsernameNotFoundException("์ฌ์ฉ์๋ฅผ ์ฐพ์ ์ ์์ต๋๋ค."));
UserDetails userDetails = new CustomUserDetails(user);
String accessToken = jwtTokenProvider.generateAccessToken(userDetails);
String refreshToken = jwtTokenProvider.generateRefreshToken(userDetails);
// ํ๋ก ํธ์๋๋ก ํ ํฐ์ ์ฟผ๋ฆฌ ํ๋ผ๋ฏธํฐ๋ก ์ ๋ฌ (์ค๋ฌด์์๋ HttpOnly ์ฟ ํค ๊ถ์ฅ)
String targetUrl = UriComponentsBuilder.fromUriString(redirectUri)
.queryParam("access_token", accessToken)
.queryParam("refresh_token", refreshToken)
.build().toUriString();
getRedirectStrategy().sendRedirect(request, response, targetUrl);
}
}SecurityConfig์ OAuth2 ์ค์ ์ถ๊ฐ
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/login/**", "/api/auth/**").permitAll()
.anyRequest().authenticated()
)
.oauth2Login(oauth2 -> oauth2
.userInfoEndpoint(userInfo -> userInfo
.userService(customOAuth2UserService)
)
.successHandler(oAuth2AuthenticationSuccessHandler)
.failureHandler((request, response, exception) -> {
response.sendRedirect("/login?error=oauth2");
})
)
.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
return http.build();
}์ธ์ ๊ธฐ๋ฐ vs JWT ๊ธฐ๋ฐ ์ธ์ฆ ๋น๊ต
๋ ๋ฐฉ์์ ๊ฐ๊ฐ ์ฅ๋จ์ ์ด ์์ผ๋ฉฐ, ํ๋ก์ ํธ์ ์ํคํ ์ฒ์ ์๊ตฌ์ฌํญ์ ๋ฐ๋ผ ์ ํํด์ผ ํ๋ค.
| ํญ๋ชฉ | ์ธ์ ๊ธฐ๋ฐ ์ธ์ฆ | JWT ๊ธฐ๋ฐ ์ธ์ฆ |
|---|---|---|
| ์ํ ์ ์ฅ | ์๋ฒ ๋ฉ๋ชจ๋ฆฌ/Redis์ ์ธ์ ์ ์ฅ (Stateful) | ์๋ฒ ๋ฌด์ํ (Stateless) |
| ํ์ฅ์ฑ | ์๋ฒ ์ฆ์ค ์ ์ธ์ ๊ณต์ ํ์ (Redis ๋ฑ) | ํ ํฐ ์์ฒด์ ์ ๋ณด ํฌํจ, ์๋ฒ ๊ฐ ๊ณต์ ๋ถํ์ |
| ํ ํฐ ๋ฌดํจํ | ์ธ์ ์ญ์ ๋ง์ผ๋ก ์ฆ์ ๋ก๊ทธ์์ ๊ฐ๋ฅ | ๋ง๋ฃ ์ ๊ฐ์ ๋ฌดํจํ ์ด๋ ค์ (๋ธ๋๋ฆฌ์คํธ ํ์) |
| ์ ์ฅ ์์น | ์๋ฒ ์ธ์ + ๋ธ๋ผ์ฐ์ ์ฟ ํค (Session ID) | ํด๋ผ์ด์ธํธ (localStorage ๋๋ HttpOnly ์ฟ ํค) |
| ๋ณด์ ์ํ | CSRF ๊ณต๊ฒฉ์ ์ทจ์ฝ (CSRF ํ ํฐ์ผ๋ก ๋ฐฉ์ด) | XSS ๊ณต๊ฒฉ์ ์ทจ์ฝ (HttpOnly ์ฟ ํค๋ก ์ํ) |
| ๋คํธ์ํฌ ๋ถํ | Session ID๋ง ์ ์ก (๊ฐ๋ฒผ์) | ํ ํฐ ํฌ๊ธฐ๋งํผ ๋งค ์์ฒญ๋ง๋ค ์ ์ก |
| ์ ํฉํ ํ๊ฒฝ | ์ ํต์ ์ธ MVC ์น ์ ํ๋ฆฌ์ผ์ด์ | REST API, ๋ง์ดํฌ๋ก์๋น์ค, ๋ชจ๋ฐ์ผ ์ฑ |
| ๊ตฌํ ๋ณต์ก๋ | Spring Security ๊ธฐ๋ณธ ์ ๊ณต์ผ๋ก ๋จ์ | ํ ํฐ ๋ฐ๊ธ/๊ฒ์ฆ/๊ฐฑ์ ๋ก์ง ์ง์ ๊ตฌํ ํ์ |
์ ํ ๊ธฐ์ค
- ์ธ์ ๊ธฐ๋ฐ: ๋จ์ผ ์๋ฒ ๋ฐฐํฌ, ์ฆ๊ฐ์ ์ธ ๋ก๊ทธ์์์ด ์ค์ํ ๊ฒฝ์ฐ, ์ ํต์ ์ธ ์๋ฒ ์ฌ์ด๋ ๋ ๋๋ง
- JWT ๊ธฐ๋ฐ: REST API ์๋ฒ, ๋ฉํฐ ์๋ฒ/๋ง์ดํฌ๋ก์๋น์ค, ๋ชจ๋ฐ์ผ ์ฑ๊ณผ์ ํต์ , OAuth2 ์ฐ๋
CSRF ๋ฐ CORS ์ค์
CSRF (Cross-Site Request Forgery)
CSRF๋ ์ธ์ฆ๋ ์ฌ์ฉ์์ ๋ธ๋ผ์ฐ์ ๋ฅผ ํตํด ์ ์์ ์ธ ์์ฒญ์ ๋ณด๋ด๋ ๊ณต๊ฒฉ์ด๋ค. Spring Security๋ ๊ธฐ๋ณธ์ ์ผ๋ก CSRF ๋ฐฉ์ด๋ฅผ ํ์ฑํํ๋ค.
REST API (Stateless) ์ ๊ฒฝ์ฐ, ์ธ์ ์ฟ ํค๋ฅผ ์ฌ์ฉํ์ง ์๊ธฐ ๋๋ฌธ์ CSRF ๊ณต๊ฒฉ์ด ์๋ฏธ ์์ผ๋ฉฐ ๋นํ์ฑํํ๋ ๊ฒ์ด ์ผ๋ฐ์ ์ด๋ค.
// REST API - CSRF ๋นํ์ฑํ
http.csrf(csrf -> csrf.disable());
// ์ ํต์ ์ธ ์น ์ฑ - CSRF ํ์ฑํ (๊ธฐ๋ณธ๊ฐ)
http.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
);CORS (Cross-Origin Resource Sharing)
ํ๋ก ํธ์๋(์: http://localhost:3000)์ ๋ฐฑ์๋(์: http://localhost:8080)๊ฐ ๋ถ๋ฆฌ๋ ํ๊ฒฝ์์๋ CORS ์ค์ ์ด ํ์๋ค.
@Configuration
public class CorsConfig {
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
// ํ์ฉํ ์ถ์ฒ (ํ๋ก๋์
์์๋ ์ค์ ๋๋ฉ์ธ๋ง ์ง์ )
configuration.setAllowedOrigins(List.of(
"http://localhost:3000",
"https://your-frontend-domain.com"
));
// ํ์ฉํ HTTP ๋ฉ์๋
configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
// ํ์ฉํ ํค๋
configuration.setAllowedHeaders(List.of("Authorization", "Content-Type", "X-Requested-With"));
// ์ธ์ฆ ์ ๋ณด(์ฟ ํค ๋ฑ) ํฌํจ ํ์ฉ
configuration.setAllowCredentials(true);
// Preflight ๊ฒฐ๊ณผ ์บ์ ์๊ฐ (์ด)
configuration.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
}SecurityFilterChain์ CORS ์ ์ฉ:
http.cors(cors -> cors.configurationSource(corsConfigurationSource()));์ค์: Spring Security์ CORS ์ค์ ๊ณผ
@CrossOrigin์ด๋ ธํ ์ด์ ์ด ์ถฉ๋ํ ์ ์๋ค. Spring Security ํํฐ๊ฐ ๋จผ์ ์คํ๋๋ฏ๋ก, CORS ์ค์ ์ ๋ฐ๋์ Security ๋ ๋ฒจ์์ ์ฒ๋ฆฌํด์ผ ํ๋ค.
๋ฐ์ ๊ฐ๋ฅํ ๋ฌธ์ ๋ถ์ ๋ฐ ์ค๋ฅ ํด๊ฒฐ ๋ฐฉ๋ฒ 2
์๋ชป๋ ์ฌ์ฉ์ ์ ๋ ฅ ์ฒ๋ฆฌ
์ฌ์ฉ์๊ฐ ์ ๊ณตํ๋ ์ ๋ณด๋ ์ข ์ข ๋ถ์ ํํ ์ ์๋ค. ์ ํ๋ฆฌ์ผ์ด์ ์ด ์๋ชป๋ ์ ๋ ฅ์ ๊ฐ์งํ๊ณ , ๋ช ํํ ์๋ฌ ๋ฉ์์ง๋ฅผ ์ฌ์ฉ์์๊ฒ ์ ๊ณตํด์ผ ํ๋ค.
ํด๊ฒฐ ๋ฐฉ๋ฒ
์ ํจ์ฑ ๊ฒ์ฌ(validation)๋ฅผ ๋์
ํด์ผ ํ๋ค. Java์์๋ jakarta.validation.constraints ํจํค์ง(Spring Boot 3.x ๊ธฐ์ค)๋ฅผ ์ฌ์ฉํ์ฌ ์ฝ๊ฒ ์ ํจ์ฑ ๊ฒ์ฌ๋ฅผ ์ค์ ํ ์ ์๋ค.
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public class LoginRequest {
@NotBlank(message = "์ด๋ฉ์ผ์ ํ์ ์
๋ ฅ ์ฌํญ์
๋๋ค.")
@Email(message = "์ด๋ฉ์ผ ํ์์ด ์๋ชป๋์์ต๋๋ค.")
private String email;
@NotBlank(message = "๋น๋ฐ๋ฒํธ๋ ํ์ ์
๋ ฅ ์ฌํญ์
๋๋ค.")
@Size(min = 8, message = "๋น๋ฐ๋ฒํธ๋ 8์ ์ด์์ด์ด์ผ ํฉ๋๋ค.")
private String password;
}์ปจํธ๋กค๋ฌ์์ @Valid ์ด๋
ธํ
์ด์
์ผ๋ก ๊ฒ์ฆ์ ํ์ฑํํ๊ณ , ์ ์ญ ์์ธ ํธ๋ค๋ฌ๋ก ์๋ฌ ์๋ต์ ์ผ๊ด์ฑ ์๊ฒ ๋ฐํํ๋ค.
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, String>> handleValidationErrors(
MethodArgumentNotValidException ex) {
Map<String, String> errors = new HashMap<>();
ex.getBindingResult().getFieldErrors().forEach(error ->
errors.put(error.getField(), error.getDefaultMessage())
);
return ResponseEntity.badRequest().body(errors);
}
}์๋ฒ ์ค๋ฅ ์ง๋จ
์๋ฒ์์ ๋ฐ์ํ๋ ์ค๋ฅ๋ ์ฌ์ฉ์๊ฐ ์ง์ ํด๊ฒฐํ๊ธฐ ์ด๋ ค์ด ๋ฌธ์ ์ด๋ค. ์๋ฅผ ๋ค์ด, ๋ฐ์ดํฐ๋ฒ ์ด์ค ์ฐ๊ฒฐ ์ค๋ฅ๊ฐ ๋ฐ์ํ๋ฉด ์๋ฒ๋ ๋ ์ด์ ์์ฒญ์ ์ฒ๋ฆฌํ ์ ์๋ค.
ํด๊ฒฐ ๋ฐฉ๋ฒ
์์ธ ์ฒ๋ฆฌ๋ฅผ ํตํด ์ด๋ฌํ ์ค๋ฅ๋ฅผ ํฌ์ฐฉํ๊ณ , ์ค๋ฅ ๋ฐ์ ์ ํด๋น ์ค๋ฅ๋ฅผ ๋ก๊ทธ์ ๊ธฐ๋กํด์ผ ํ๋ค.
try {
// ๋ฐ์ดํฐ๋ฒ ์ด์ค ์ฐ๊ฒฐ ์๋
connectToDatabase();
} catch (DatabaseConnectionException e) {
log.error("๋ฐ์ดํฐ๋ฒ ์ด์ค ์ฐ๊ฒฐ์ ์คํจํ์ต๋๋ค: {}", e.getMessage());
throw new ServerException("์๋ฒ ์ค๋ฅ๊ฐ ๋ฐ์ํ์ต๋๋ค. ๋์ค์ ๋ค์ ์๋ํด์ฃผ์ธ์.");
}์ธ์ฆ ์คํจ ์ค๋ฅ
์ฌ์ฉ์์ ์๋ชป๋ ์๊ฒฉ ์ฆ๋ช
, ๋น์ ์์ ์ธ AuthenticationManager ์ค์ , ๋๋ ๋น๋ฐ๋ฒํธ๊ฐ ํ๋ฌธ์ผ๋ก ์ ์ฅ๋ ๊ฒฝ์ฐ ๋ฑ์์ ๋ฐ์ํ๋ค.
ํด๊ฒฐ ๋ฐฉ๋ฒ
log.info("Login attempt for user: {}", user.getEmail());
try {
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
user.getEmail(), user.getPassword()));
log.info("Authentication successful for user: {}", user.getEmail());
} catch (BadCredentialsException e) {
log.error("Authentication failed: {}", e.getMessage());
throw new BadCredentialsException("์ด๋ฉ์ผ ๋๋ ๋น๋ฐ๋ฒํธ๊ฐ ์ฌ๋ฐ๋ฅด์ง ์์ต๋๋ค.");
} catch (DisabledException e) {
throw new DisabledException("๋นํ์ฑํ๋ ๊ณ์ ์
๋๋ค. ๊ด๋ฆฌ์์๊ฒ ๋ฌธ์ํ์ธ์.");
}SecurityContextHolder ์ค์ ๋ฌธ์
์ธ์ฆ์ด ์ฑ๊ณตํ์์๋ ๋ถ๊ตฌํ๊ณ SecurityContextHolder์ ์ธ์ฆ ์ ๋ณด๊ฐ ์ ์ฅ๋์ง ์์์ ์ดํ์ ๋ณด์ ๊ด๋ จ ์์
์ด ์คํจํ๋ ๊ฒฝ์ฐ๊ฐ ์๋ค. ๋ณดํต SecurityContextHolder๋ฅผ ์ฌ๋ฐ๋ฅด๊ฒ ๊ตฌ์ฑํ์ง ์์๊ฑฐ๋, ์ธ์ฆ ํ ์๋ต์ด ๋ฐํ๋๊ธฐ ์ ์ ์ปจํ
์คํธ๊ฐ ์ด๊ธฐํ๋์์ ๋ ๋ฐ์ํ๋ค.
ํด๊ฒฐ ๋ฐฉ๋ฒ
SecurityContextHolder.getContext().setAuthentication(authentication); ํธ์ถ์ด ์ฌ๋ฐ๋ฅด๊ฒ ์ด๋ฃจ์ด์ก๋์ง, ๊ทธ๋ฆฌ๊ณ ์ด ํธ์ถ ํ ์ปจํ
์คํธ๊ฐ ๋ค๋ฅธ ๊ณณ์์ ์ด๊ธฐํ๋์ง ์์๋์ง ํ์ธํด์ผ ํ๋ค.
๋น๋๊ธฐ ์ฒ๋ฆฌ์์์ ์ธ์ฆ ๋ฌธ์
๋น๋๊ธฐ ์์ฒญ ์ฒ๋ฆฌ ์ค ์ธ์ฆ ์ ๋ณด๊ฐ ์ ๋๋ก ์ ๋ฌ๋์ง ์๊ฑฐ๋ ์์ค๋๋ ๊ฒฝ์ฐ๊ฐ ์๋ค. Spring Security๋ ๊ธฐ๋ณธ์ ์ผ๋ก ์ค๋ ๋ ๋ก์ปฌ(ThreadLocal)์ ์ฌ์ฉํ์ฌ SecurityContext๋ฅผ ๊ด๋ฆฌํ๋ฏ๋ก, ๋น๋๊ธฐ ํ๊ฒฝ์์๋ ์ธ์ฆ ์ ๋ณด๊ฐ ์ค๋ ๋ ๊ฐ์ ์ฌ๋ฐ๋ฅด๊ฒ ์ ๋ฌ๋์ง ์์ ์ ์๋ค.
ํด๊ฒฐ ๋ฐฉ๋ฒ
DelegatingSecurityContextExecutor๋ฅผ ์ฌ์ฉํ์ฌ ๋น๋๊ธฐ ํ๊ฒฝ์์๋ SecurityContext๊ฐ ์ ํ๋๋๋ก ์ค์ ํ๋ค.
@Bean
public Executor taskExecutor() {
return new DelegatingSecurityContextExecutor(new SimpleAsyncTaskExecutor());
}์ค๋ฌด์์ ์์ฃผ ๋ฐ์ํ๋ ์ถ๊ฐ ์ค๋ฅ๋ค
401 Unauthorized vs 403 Forbidden
๋ ์ํ ์ฝ๋๋ ๋ชจ๋ ์ ๊ทผ ๊ฑฐ๋ถ๋ฅผ ๋ํ๋ด์ง๋ง ์๋ฏธ๊ฐ ๋ค๋ฅด๋ค.
| ์ํ ์ฝ๋ | ์๋ฏธ | ๋ฐ์ ์ํฉ | Spring Security ์ฒ๋ฆฌ ํด๋์ค |
|---|---|---|---|
| 401 Unauthorized | ์ธ์ฆ ์ ๋ณด ์์ ๋๋ ์ ํจํ์ง ์์ | ํ ํฐ ๋ฏธํฌํจ, ๋ง๋ฃ, ์๋ณ์กฐ | AuthenticationEntryPoint |
| 403 Forbidden | ์ธ์ฆ์ ๋์ง๋ง ๊ถํ ๋ถ์กฑ | ROLE_USER๋ก ADMIN ์ ์ฉ API ์ ๊ทผ | AccessDeniedHandler |
์ค๋ฌด์์๋ ์ด ๋ ์ํ๋ฅผ ๋ช ํํ ๊ตฌ๋ถํ์ฌ ํด๋ผ์ด์ธํธ๊ฐ ์ ์ ํ ์กฐ์น๋ฅผ ์ทจํ ์ ์๋๋ก ํด์ผ ํ๋ค.
@Component
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) throws IOException {
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.getWriter().write("""
{"error": "UNAUTHORIZED", "message": "์ธ์ฆ์ด ํ์ํฉ๋๋ค."}
""");
}
}
@Component
public class CustomAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response,
AccessDeniedException accessDeniedException) throws IOException {
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.getWriter().write("""
{"error": "FORBIDDEN", "message": "์ ๊ทผ ๊ถํ์ด ์์ต๋๋ค."}
""");
}
}SecurityConfig์ ํธ๋ค๋ฌ ๋ฑ๋ก:
http.exceptionHandling(exceptions -> exceptions
.authenticationEntryPoint(customAuthenticationEntryPoint)
.accessDeniedHandler(customAccessDeniedHandler)
);CORS ์ค๋ฅ์ Spring Security
์ฆ์: ๋ธ๋ผ์ฐ์ ๊ฐ๋ฐ์ ๋๊ตฌ์์ Access-Control-Allow-Origin ์ค๋ฅ๊ฐ ๋ฐ์ํ๊ฑฐ๋, Preflight OPTIONS ์์ฒญ์ด 401์ ๋ฐํํ๋ ๊ฒฝ์ฐ๋ค.
์์ธ: Spring Security ํํฐ๊ฐ CORS Preflight ์์ฒญ์ ๊ฐ๋ก์ฑ์ด ์ธ์ฆ์ ์๊ตฌํ๋ ๊ฒฝ์ฐ, ๋ธ๋ผ์ฐ์ ๊ฐ ์ค์ ์์ฒญ ์ ์ ๋ณด๋ด๋ OPTIONS ์์ฒญ์ด ์ธ์ฆ ์ค๋ฅ๋ก ์ฐจ๋จ๋๋ค.
ํด๊ฒฐ ๋ฐฉ๋ฒ: OPTIONS ์์ฒญ์ ๋ช ์์ ์ผ๋ก ํ์ฉํ๊ฑฐ๋, Spring Security CORS ์ค์ ์ ์ฌ๋ฐ๋ฅด๊ฒ ์ ์ฉํ๋ค.
http
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.authorizeHttpRequests(auth -> auth
// OPTIONS ์์ฒญ์ ์ธ์ฆ ์์ด ํ์ฉ
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
.anyRequest().authenticated()
);๋ํ @CrossOrigin ์ด๋
ธํ
์ด์
๊ณผ Spring Security CORS ์ค์ ์ด ์ค๋ณต์ผ๋ก ์ ์ฉ๋๋ฉด ์์์น ๋ชปํ ๋์์ด ๋ฐ์ํ ์ ์์ผ๋ฏ๋ก, ํ ๊ณณ์์๋ง ์ค์ ํ๋ ๊ฒ์ ๊ถ์ฅํ๋ค.
JWT ๋ง๋ฃ ์ฒ๋ฆฌ
JWT๋ ๋ง๋ฃ๋๋ฉด ๋ ์ด์ ์ ํจํ์ง ์์ง๋ง, ํด๋ผ์ด์ธํธ๋ ์ด๋ฅผ ์ธ์งํ์ง ๋ชปํ ์ ์๋ค. ์ค๋ฌด์์๋ Access Token๊ณผ Refresh Token์ ๋ถ๋ฆฌํ์ฌ ๊ด๋ฆฌํ๋ ๊ฒ์ด ์ผ๋ฐ์ ์ด๋ค.
| ์๋๋ฆฌ์ค | ์ฒ๋ฆฌ ๋ฐฉ๋ฒ |
|---|---|
| Access Token ๋ง๋ฃ | 401 ์๋ต ๋ฐํ, ํด๋ผ์ด์ธํธ๊ฐ Refresh Token์ผ๋ก ์ฌ๋ฐ๊ธ ์์ฒญ |
| Refresh Token ๋ง๋ฃ | ์ฌ๋ก๊ทธ์ธ ์ ๋ |
| Refresh Token ํ์ทจ ์์ฌ | DB์์ ํด๋น ํ ํฐ ์ญ์ (์ฆ์ ๋ฌดํจํ) |
// JwtAuthenticationFilter์์ ๋ง๋ฃ ์ฒ๋ฆฌ
try {
username = jwtTokenProvider.extractUsername(jwt);
} catch (ExpiredJwtException e) {
// Access Token ๋ง๋ฃ: ํด๋ผ์ด์ธํธ์๊ฒ ๊ฐฑ์ ํ์ ์ ํธ
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setHeader("X-Token-Expired", "true"); // ์ปค์คํ
ํค๋๋ก ๋ง๋ฃ ์ฌ๋ถ ์ ๋ฌ
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.getWriter().write("""
{"error": "TOKEN_EXPIRED", "message": "ํ ํฐ์ด ๋ง๋ฃ๋์์ต๋๋ค. ๊ฐฑ์ ์ด ํ์ํฉ๋๋ค."}
""");
return;
}ํด๋ผ์ด์ธํธ(์: React + Axios)์์๋ Interceptor๋ฅผ ํตํด ์ด๋ฅผ ์๋์ผ๋ก ์ฒ๋ฆฌํ ์ ์๋ค.
// Axios Interceptor ์์ (์ฐธ๊ณ ์ฉ)
axios.interceptors.response.use(
response => response,
async error => {
if (error.response?.status === 401 &&
error.response?.headers['x-token-expired']) {
// Refresh Token์ผ๋ก Access Token ์๋ ๊ฐฑ์
const { data } = await axios.post('/api/auth/refresh', {
refreshToken: localStorage.getItem('refreshToken')
});
// ์ ํ ํฐ์ผ๋ก ์๋ ์์ฒญ ์ฌ์๋
error.config.headers['Authorization'] = `Bearer ${data.accessToken}`;
return axios(error.config);
}
return Promise.reject(error);
}
);๋ณด์ ๋ฒ ์คํธ ํ๋ํฐ์ค 3 4
๋ณด์์ฑ์ ๋์ด๊ธฐ ์ํด ๊ฐ๋ฐ์๋ ๋ชจ๋ฒ ์ฌ๋ก๋ฅผ ์ค์ํด์ผ ํ๋ค.
ํ์ธ๋ ์์ ํ ๋ผ์ด๋ธ๋ฌ๋ฆฌ ์ฌ์ฉ
ํญ์ ์ต๊ทผ ๋ฒ์ ์ ๋ผ์ด๋ธ๋ฌ๋ฆฌ๋ฅผ ์ ์งํ๊ณ ์๋ ค์ง ์ทจ์ฝ์ ์ ์ฃผ๊ธฐ์ ์ผ๋ก ์ ๊ฒํด์ผ ํ๋ค. spring-boot-starter-parent๋ฅผ ์ฌ์ฉํ๋ฉด ๊ฒ์ฆ๋ ์์กด์ฑ ๋ฒ์ ์กฐํฉ์ ์๋์ผ๋ก ๊ด๋ฆฌ๋ฐ์ ์ ์๋ค.
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.4.0</version>
</parent>HTTP ๋ณด์ ํค๋ ์ค์
Spring Security๋ ๊ธฐ๋ณธ์ ์ผ๋ก ์ฌ๋ฌ ๋ณด์ ํค๋๋ฅผ ์๋์ผ๋ก ์ถ๊ฐํ๋ค. ์ถ๊ฐ์ ์ธ ์ค์ ์ด ํ์ํ ๊ฒฝ์ฐ ๋ค์๊ณผ ๊ฐ์ด ๊ตฌ์ฑํ ์ ์๋ค.
http.headers(headers -> headers
.contentSecurityPolicy(csp -> csp
.policyDirectives("script-src 'self'; object-src 'none'; base-uri 'self'")
)
.frameOptions(frame -> frame.deny()) // Clickjacking ๋ฐฉ์ง
.xssProtection(xss -> xss.disable()) // ๋ชจ๋ ๋ธ๋ผ์ฐ์ ๋ CSP๋ก ๋์ฒด
.httpStrictTransportSecurity(hsts -> hsts
.includeSubDomains(true)
.maxAgeInSeconds(31536000) // 1๋
)
);๋ฐ์ดํฐ ๋ช ๋ น์ด ๋ถ๋ฆฌ (SQL ์ฃผ์ ๋ฐฉ์ด)
๋ฐ์ดํฐ๋ฅผ ์ฒ๋ฆฌํ๋ ๋ถ๋ถ๊ณผ ๋ช ๋ น์ด ์ํ ๋ถ๋ถ์ ์ฒ ์ ํ ๋ถ์ฐํ์ฌ SQL ์ฃผ์ ๊ณต๊ฒฉ์ ์๋ฐฉํด์ผ ํ๋ค. Spring Data JPA๋ฅผ ์ฌ์ฉํ๋ฉด ๋๋ถ๋ถ์ ๊ฒฝ์ฐ ์๋์ผ๋ก ๋ฐฉ์ด๋๋ค.
// ์์ : Spring Data JPA์ ํ๋ผ๋ฏธํฐ ๋ฐ์ธ๋ฉ
@Query("SELECT u FROM User u WHERE u.email = :email")
Optional<User> findByEmail(@Param("email") String email);
// ์์ : PreparedStatement ์ฌ์ฉ
String query = "SELECT * FROM users WHERE email = ?";
PreparedStatement statement = connection.prepareStatement(query);
statement.setString(1, userEmail);
ResultSet resultSet = statement.executeQuery();
// ์ํ: ๋ฌธ์์ด ์ง์ ์ฐ๊ฒฐ (์ ๋ ์ฌ์ฉ ๊ธ์ง)
// String query = "SELECT * FROM users WHERE email = '" + userEmail + "'";๋ฏผ๊ฐํ ์ ๋ณด ๊ด๋ฆฌ
๋น๋ฐํค, DB ํจ์ค์๋, OAuth2 ํด๋ผ์ด์ธํธ ์ํฌ๋ฆฟ ๋ฑ ๋ฏผ๊ฐํ ์ ๋ณด๋ ์ ๋ ์์ค์ฝ๋์ ์ง์ ํฌํจํ์ง ์๋๋ค.
# ํ๊ฒฝ ๋ณ์ ๋๋ AWS Secrets Manager, Vault ๋ฑ์ ํตํด ์ฃผ์
jwt:
secret: ${JWT_SECRET}
spring:
security:
oauth2:
client:
registration:
google:
client-id: ${GOOGLE_CLIENT_ID}
client-secret: ${GOOGLE_CLIENT_SECRET}๊ณ์ ์ ๊ธ ์ ์ฑ
๋ฌด์ฐจ๋ณ ๋์ ๊ณต๊ฒฉ(Brute-force)์ ๋ฐฉ์ดํ๊ธฐ ์ํด ์ผ์ ํ์ ์ด์ ๋ก๊ทธ์ธ ์คํจ ์ ๊ณ์ ์ ์ ๊ธํ๋ ์ ์ฑ ์ ๊ตฌํํ๋ค.
@Service
@RequiredArgsConstructor
public class LoginAttemptService {
private static final int MAX_ATTEMPT = 5;
private final Map<String, Integer> attemptsCache = new ConcurrentHashMap<>();
public void loginFailed(String email) {
int attempts = attemptsCache.getOrDefault(email, 0);
attemptsCache.put(email, attempts + 1);
}
public void loginSucceeded(String email) {
attemptsCache.remove(email);
}
public boolean isBlocked(String email) {
return attemptsCache.getOrDefault(email, 0) >= MAX_ATTEMPT;
}
}์ค์ ์ด์ ํ๊ฒฝ์์๋ attemptsCache๋ฅผ Redis๋ก ๋์ฒดํ๊ณ , ์ ๊ธ ํด์ ์๊ฐ(TTL)์ ์ค์ ํ๋ ๊ฒ์ด ์ข๋ค.
๊ฒฐ๋ก
์ด ๊ธ์์๋ Spring Security์ Filter Chain ์ ์ฒด ๋์ ํ๋ฆ๋ถํฐ ์์ํ์ฌ, JWT ๊ธฐ๋ฐ ์ธ์ฆ ๊ตฌํ, OAuth2 ์์ ๋ก๊ทธ์ธ ์ฐ๋, Spring Security 6.x์ Lambda DSL ์ค์ ๋ฐฉ์, ๊ทธ๋ฆฌ๊ณ ์ค๋ฌด์์ ์์ฃผ ๋ง์ฃผ์น๋ ์ค๋ฅ์ ๊ทธ ํด๊ฒฐ ๋ฐฉ๋ฒ๊น์ง ํญ๋๊ฒ ์ดํด๋ณด์๋ค.
์ ๋ฆฌํ๋ฉด ๋ค์๊ณผ ๊ฐ๋ค.
- Filter Chain์ Spring Security์ ์ฌ์ฅ๋ถ๋ก, ์์ฒญ์ด ์ปจํธ๋กค๋ฌ์ ๋๋ฌํ๊ธฐ ์ ์ ์ธ์ฆ๊ณผ ์ธ๊ฐ๋ฅผ ์ฒ๋ฆฌํ๋ค.
- JWT๋ Stateless ์ํคํ ์ฒ์ ์ ํฉํ๋ฉฐ, Access Token๊ณผ Refresh Token์ ๋ถ๋ฆฌํ์ฌ ๋ณด์์ฑ๊ณผ ์ฌ์ฉ์ฑ์ ๊ท ํ ์๊ฒ ์ ์งํด์ผ ํ๋ค.
- OAuth2๋ ์์
๋ก๊ทธ์ธ์ ๊ฐ๋จํ๊ฒ ๊ตฌํํ ์ ์๊ฒ ํด์ฃผ๋ฉฐ,
CustomOAuth2UserService๋ฅผ ํตํด ์์ฒด DB์ ์ฐ๋ํ ์ ์๋ค. - Spring Security 6.x์์๋
WebSecurityConfigurerAdapter๋์ Lambda DSL ๋ฐฉ์์ ์ฌ์ฉํด์ผ ํ๋ค. - 401๊ณผ 403์ ์ฐจ์ด๋ฅผ ๋ช ํํ ๊ตฌ๋ถํ๊ณ , CORS์ CSRF ์ค์ ์ ์ํคํ ์ฒ์ ๋ง๊ฒ ์ ์ ํ ๊ตฌ์ฑํด์ผ ํ๋ค.
๊ฐ ์ฃผ์ ๋ฅผ ์ข ํฉ์ ์ผ๋ก ๊ณ ๋ คํ์ฌ ์์ ์ ์ด๊ณ ํจ์จ์ ์ธ ์ฝ๋๋ฅผ ์์ฑํ๊ธธ ๋ฐ๋๋ค.
- Marco Behler - Spring Security: Authentication and Authorization In-Depth (https://www.marcobehler.com/guides/spring-security)โฉ
- Medium - Securing Spring Boot Applications: Best Practices and ... (https://medium.com/@shubhamvartak01/securing-spring-boot-applications-best-practices-and-strategies-3ab731f8b317)โฉ
- Spring - Getting Started | Securing a Web Application (https://spring.io/guides/gs/securing-web)โฉ
- Synopsys - Top 10 Spring Security Best Practices for Java Developers (https://www.synopsys.com/blogs/software-security/spring-security-best-practices.html)โฉ
