1. 토큰 인증
1️⃣ 토큰 인증이란?
1) 핵심 아이디어
- 서버가 로그인 성공 시 ‘이 사용자는 인증됨’을 나타내는 토큰 문자열을 발급
- 이후 클라이언트는 요청마다 토큰을 실어 보내고,
- 서버는 토큰을 검증해서 사용자 식별/권한 확인을 함
2) 세션 인증 vs 토큰 인증
- 세션
- 서버 메모리/DB에 세션 상태를 저장
- 클라이언트는 세션ID만 들고 다님 (상태ful)
- 토큰
- 인증 정보(또는 그 참조)를 토큰으로 들고 다님
- 서버는 상태를 안 들고도 가능 (상태less 가능)
2️⃣ 토큰 인증의 대표 유형 2가지
1) JWT(JSON Web Token) — 자기 포함(Self-contained)
- 토큰 안에 클레임(사용자ID, 만료시간, 권한 등)이 들어있음
- 서버는 서명(Signature) 검증으로 위변조 여부 확인
- 장단점
- 장점: 서버가 DB 조회 없이도 검증 가능(확장성)
- 단점: 토큰이 탈취되면 만료 전까지 위험(폐기/차단 설계가 중요)
- JWT 구조
header.payload.signature- payload는 보통 Base64URL 인코딩이라 암호화가 아니라 “보여도 됨”(민감정보 넣으면 안 됨)
2) Opaque Token(랜덤 토큰) — 참조형(Reference)
- 토큰 자체는 의미 없는 랜덤 문자열
- 서버/인증서버가 토큰 저장소(DB/Redis)에서 찾아서 유효성 확인(또는 introspection)
- 장단점
- 장점: 중앙에서 강제 폐기/회수 쉬움
- 단점: 매 요청마다 저장소 조회 필요(캐시로 완화)
3️⃣ 동작원리(보안 관점에서 무엇을 검증하나)
서버가 보는 검증 포인트(일반적으로):
- 서명/무결성(JWT라면 signature 검증)
- 만료(exp) 및 발급자(iss), 대상(aud) 같은 표준 클레임
- 권한/스코프(scope, role) 확인 (인가 Authorization 단계)
- (선택) 폐기 목록(blacklist), 토큰 버전, 리프레시 토큰 회전 여부 등
4️⃣ 스프링에서 JWT 토큰 인증 실행흐름
1) 로그인 /auth/login
- 클라이언트가
username/password전송
- 서버가
AuthenticationManager로 인증
- 성공 시:
- Access Token(JWT, 짧게) 발급
- Refresh Token(랜덤, 길게) 발급 + DB/Redis 저장
- 클라이언트에 반환(보통 JSON)
2) API 호출
- 요청 헤더에
Authorization: Bearer <accessToken>
- JWT 인증 필터가 토큰을 파싱/검증
- 유효하면
SecurityContext에Authentication세팅
- 컨트롤러/서비스는
@AuthenticationPrincipal또는SecurityContextHolder로 사용자 조회
3) Access 만료 → /auth/refresh
- Access 만료로 API에서 401
- 클라이언트가 refresh token으로
/auth/refresh
- 서버가 refresh token을 저장소에서 확인(유효/만료/폐기 여부)
- 새 access 발급(권장: refresh rotation도 함께)
5️⃣ 예시 코드
1) JWT 토큰 Provider
package com.example.demo.auth;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import org.springframework.stereotype.Component;
import java.security.Key;
import java.time.Instant;
import java.util.Date;
import java.util.Map;
@Component
public class JwtTokenProvider {
// 실습용. 운영에선 환경변수/Secret Manager + 충분히 긴 키 사용
private final Key key = Keys.hmacShaKeyFor(
"dev-secret-dev-secret-dev-secret-dev-secret-1234".getBytes()
);
private final String issuer = "my-api";
private final String audience = "my-client";
public String createAccessToken(String userId, String role, long ttlSeconds) {
Instant now = Instant.now();
Instant exp = now.plusSeconds(ttlSeconds);
return Jwts.builder()
.issuer(issuer)
.audience().add(audience).and()
.subject(userId) // sub
.claims(Map.of("role", role)) // 커스텀 클레임
.issuedAt(Date.from(now))
.expiration(Date.from(exp))
.signWith(key)
.compact();
}
public Jws<Claims> parseAndValidate(String token) {
return Jwts.parser()
.requireIssuer(issuer)
.requireAudience(audience)
.verifyWith((javax.crypto.SecretKey) key)
.build()
.parseSignedClaims(token);
}
}2) JWT 인증 필터 (Bearer 토큰 처리)
package com.example.demo.auth;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.List;
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 auth = request.getHeader("Authorization");
if (auth != null && auth.startsWith("Bearer ")) {
String token = auth.substring(7);
try {
Jws<Claims> jws = jwtTokenProvider.parseAndValidate(token);
Claims claims = jws.getPayload();
String userId = claims.getSubject();
String role = claims.get("role", String.class);
var authorities = List.of(new SimpleGrantedAuthority("ROLE_" + role));
var authentication =
new UsernamePasswordAuthenticationToken(userId, null, authorities);
SecurityContextHolder.getContext().setAuthentication(authentication);
} catch (Exception e) {
// 토큰이 만료/위조/형식오류 등인 경우: SecurityContext 비우고 진행
SecurityContextHolder.clearContext();
// 보통은 여기서 바로 401로 끊기도 함(정책 선택)
}
}
filterChain.doFilter(request, response);
}
}3) Security 설정 (필터 체인 + 접근 규칙)
package com.example.demo.config;
import com.example.demo.auth.JwtAuthenticationFilter;
import com.example.demo.auth.JwtTokenProvider;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
public class SecurityConfig {
@Bean
SecurityFilterChain filterChain(HttpSecurity http, JwtTokenProvider jwtTokenProvider) throws Exception {
http
.csrf(csrf -> csrf.disable())
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/auth/**").permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider),
UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager();
}
}4) 실습용 사용자(인메모리) 설정
package com.example.demo.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.core.userdetails.*;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
@Configuration
public class UserConfig {
@Bean
public UserDetailsService userDetailsService(PasswordEncoder encoder) {
var user = User.withUsername("alice")
.password(encoder.encode("pass123"))
.roles("ADMIN")
.build();
return new InMemoryUserDetailsManager(user);
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}5) Refresh Token 저장/검증 (실습용: 메모리 Map)
package com.example.demo.auth;
import org.springframework.stereotype.Service;
import java.security.SecureRandom;
import java.time.Instant;
import java.util.Base64;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Service
public class RefreshTokenService {
private final SecureRandom random = new SecureRandom();
// token -> record
private final Map<String, Record> store = new ConcurrentHashMap<>();
public record Record(String userId, Instant expiresAt) {}
public String issue(String userId, long ttlSeconds) {
byte[] bytes = new byte[32];
random.nextBytes(bytes);
String token = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
store.put(token, new Record(userId, Instant.now().plusSeconds(ttlSeconds)));
return token;
}
public Record validate(String token) {
Record r = store.get(token);
if (r == null) return null;
if (r.expiresAt().isBefore(Instant.now())) {
store.remove(token);
return null;
}
return r;
}
// rotation: 기존 토큰 폐기
public void revoke(String token) {
store.remove(token);
}
}6) AuthController (로그인/리프레시)
package com.example.demo.auth;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.*;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
@RequestMapping("/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthenticationManager authenticationManager;
private final JwtTokenProvider jwtTokenProvider;
private final RefreshTokenService refreshTokenService;
private final UserDetailsService userDetailsService;
// TTL (실습용)
private final long accessTtlSec = 60 * 10; // 10분
private final long refreshTtlSec = 60 * 60 * 24 * 14; // 14일
public record LoginRequest(String username, String password) {}
public record RefreshRequest(String refreshToken) {}
@PostMapping("/login")
public Map<String, String> login(@RequestBody LoginRequest req) {
var authToken =
new UsernamePasswordAuthenticationToken(req.username(), req.password());
authenticationManager.authenticate(authToken);
var userDetails = userDetailsService.loadUserByUsername(req.username());
String userId = userDetails.getUsername(); // 실습용: username을 userId로 사용
String role = userDetails.getAuthorities().stream()
.findFirst().map(a -> a.getAuthority().replace("ROLE_", ""))
.orElse("USER");
String accessToken = jwtTokenProvider.createAccessToken(userId, role, accessTtlSec);
String refreshToken = refreshTokenService.issue(userId, refreshTtlSec);
return Map.of(
"accessToken", accessToken,
"refreshToken", refreshToken
);
}
@PostMapping("/refresh")
public Map<String, String> refresh(@RequestBody RefreshRequest req) {
var record = refreshTokenService.validate(req.refreshToken());
if (record == null) {
throw new BadCredentialsException("Invalid refresh token");
}
// rotation
refreshTokenService.revoke(req.refreshToken());
String newRefresh = refreshTokenService.issue(record.userId(), refreshTtlSec);
// role은 실습 단순화를 위해 다시 조회(실무: DB/캐시)
var userDetails = userDetailsService.loadUserByUsername(record.userId());
String role = userDetails.getAuthorities().stream()
.findFirst().map(a -> a.getAuthority().replace("ROLE_", ""))
.orElse("USER");
String newAccess = jwtTokenProvider.createAccessToken(record.userId(), role, accessTtlSec);
return Map.of(
"accessToken", newAccess,
"refreshToken", newRefresh
);
}
}7) 보호 API 예시 (현재 로그인 사용자 확인)
package com.example.demo.api;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
public class MeController {
@GetMapping("/me")
public Map<String, Object> me(Authentication authentication) {
return Map.of(
"userId", authentication.getName(),
"authorities", authentication.getAuthorities()
);
}
}8) Postman/curl로 테스트 흐름
- 로그인
curl -X POST http://localhost:8080/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"alice","password":"pass123"}'- 보호 API 호출
curl http://localhost:8080/me \
-H "Authorization: Bearer <accessToken>"- 만료되면 refresh
curl -X POST http://localhost:8080/auth/refresh \
-H "Content-Type: application/json" \
-d '{"refreshToken":"<refreshToken>"}'Share article