OAuth 2.0과 JWT를 활용한 Spring Security 로그인 개념 정리
|2026. 2. 23. 21:57
※ 이 글은 최주호님의 인프런 강의 '스프링부트 시큐리티 & JWT 강의'을 기반으로 학습한 내용을 정리한 것입니다.
세션 기반 로그인
- Web Server는 무상태이기 때문에 로그인한 사용자를 기억하지 못한다.
- 그래서 WAS는 메모리 공간에 HttpSession 객체(세션)를 생성하고, 로그인한 유저에 대한 정보를 해당 객체 내부에 저장한다. 이것이 사용자 정보를 세션에 저장한다 라는 의미이다.
- 그리고 해당 세션 정보 전체를 주지 않고, 해당 유저를 찾을 수 있는 세션 ID만 생성해서 발급해서 HTTP 응답 헤더(Set-Cookie)에 담아서 브라우저에 전달해준다.
- 이렇게 발급된 세션 ID를 브라우저는 쿠키에 저장해두고, 요청마다 요청 헤더에 자동으로 쿠키(세션 ID)를 서버에 전달해서 최초 로그인 후에는 로그인을 다시 하지 않아도 원할하게 요청을 보낼 수 있도록 한다.
- 세션은 서버가 직접 지우거나 사용자가 브라우저를 닫거나 유효시간이 지나면 사라진다.
- 하지만, 세션은 WAS의 메모리 공간에 저장되기 때문에 서버가 여러 대인 환경에서는 서버가 달라지면, 다른 서버에서는 해당 유저에 대한 세션을 메모리에 저장하고 있지 않기 때문에 로그인을 하지 못하는 문제가 발생한다.
-> 중앙 db를 두면 해결되는데 이러면 I/O 발생해서 너무 느려진다. 그래서 메모리(RAM)공유 서버에 두면 해결은 된다.(Redis 같은)
SecurityConfig
@Configuration
@EnableWebSecurity // 활성화되면 스프링 시큐리티 필터가 스프링 필터체인에 등록됨
@EnableMethodSecurity(securedEnabled = true, prePostEnabled = true) // secured 어노테이션 활성화, preAuthorize, postAuthorize 어노테이션 활성화
@RequiredArgsConstructor
public class SecurityConfig {
private final PrincipalOauth2UserService principalOauth2UserService;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// CSRF 해제
.csrf(AbstractHttpConfigurer::disable)
// 권한 설정
.authorizeHttpRequests(auth -> auth
.requestMatchers("/user/**").authenticated() // 인증만 되면 OK
.requestMatchers("/manager/**").hasAnyRole("ADMIN", "MANAGER")
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().permitAll() // 나머지는 다 접근 허용
)
// 폼 로그인 설정
.formLogin(form -> form
.loginPage("/loginForm") // 권한 없는 페이지 접속 시 튕겨낼 로그인 주소
.loginProcessingUrl("/login") // 로그인 진행 주소. 별도 로그인 페이지 없으면 시큐리티가 낚아채서 대신 로그인을 진행
.defaultSuccessUrl("/") // 로그인 성공 시 이동할 주소
)
// OAuth2 로그인 설정
.oauth2Login(oauth2 -> oauth2
.loginPage("/loginForm")
// 구글 로그인 완료된 후의 후처리가 필요함.
// 1. 코드받기(인증),
// 2. 엑세스토큰(권한),
// 3. 사용자프로필 정보 가져오기,
// 4-1. 그 정보를 토대로 자동 회원가입 진행 가능
// 4-2. (이메일, 전화번호, 이름, 아이디) 등 정보 외에 추가적인 정보 구성을 해서 회원가입 진행 가능
// OAuth Client를 쓰면 엑세스토큰+사용자프로필 정보를 같이 가져와줌
.userInfoEndpoint(userInfo -> userInfo.userService(principalOauth2UserService))
);
return http.build();
}
}
- @Secured은 특정 메서드에 권한 검증을 단순하게 걸기 위해 사용한다. -> @Secured("ROLE_ADMIN")
- @PreAuthorize은 조금 권한 검증을 구체적인 조건을 표현할 때 사용한다. 메서드가 호출되기 전에 검증한다. -> @PreAuthorize("hasRole('ROLE_MANAGER') or hasRole('ROLE_ADMIN')")
- @PostAuthorize는 메서드를 호출한 뒤에 검증한다. 거의 사용하지 않는다.
PricipalDetails
@Data
public class PrincipalDetails implements UserDetails, OAuth2User {
private User user; // 콤포지션
private Map<String, Object> attributes;
// 일반 로그인
public PrincipalDetails(User user) {
this.user = user;
}
// OAuth 로그인
public PrincipalDetails(User user, Map<String, Object> attributes) {
this.user = user;
this.attributes = attributes;
}
@Override
public Map<String, Object> getAttributes() {
return attributes;
}
// 해당 User의 권한을 리턴하는 메서드
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> collect = new ArrayList<>();
collect.add(new GrantedAuthority() {
@Override
public String getAuthority() {
return user.getRole();
}
});
return collect;
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getUsername();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
// 휴먼 계정이면 false 겠지. 이건 도메인 요구사항에 따라 다름
return true;
}
// 이거 안씀 어차피
@Override
public String getName() {
return null;
}
}
- BCryptPasswordEncoder를 빈으로 등록해두면, 스프링 시큐리티가 자동으로 사용자가 입력한 평문 비밀번호를 해싱(단방향 암호화)하여, DB에 저장된 해시값과 알아서 비교(matches)해 준다.
- 시큐리티는 /login 주소 요청이 오면 낚아채서 로그인을 진행한다.
- 이때, 로그인 진행이 완료가 되면 세션을 만들어준다. (Security ContextHolder 세션에 저장)
- 세션에 저장되는 객체는 Authentication이며, 해당 객체 안에 User 정보가 저장된다. (Authentication 객체는 UserDetails 객체와 OAuth2User 객체 타입을 가질 수 있다)
- 따라서 실제 DB 엔티티인 User를 시큐리티에 넘겨주기 위해, UserDetails와 OAuth2User를 둘 다 구현한 커스텀 클래스를 Authentication에 저장해서 cross 지원을 한다.
- 정리하자면 Security Session에 세션 정보를 저장해두는데, 여기 저장될 수 있는 객체는 Authentication 타입이고, 이 Authentication 객체 안에는 UserDetails / OAuth2User 정보가 있는 것이다.
- 즉, Session => Security Session => Authentication => UserDetails (일반 로그인) / OAuth2User (OAuth 로그인)
PrincipalDetailService
@Service
@RequiredArgsConstructor
public class PrincipalDetailService implements UserDetailsService {
private final UserRepository userRepository;
// 시큐리티 세션에 저장되는 Authentication 객체 내부에 저장됨
// 함수 종료 시 @AuthenticationPrincipal 어노테이션이 만들어짐
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userRepository.findByUsername(username);
if (user == null) {
throw new UsernameNotFoundException("해당 사용자를 찾을 수 없습니다. " + username);
}
return new PrincipalDetails(user);
}
}
- 시큐리티 설정에서 loginProcessingUrl("/login")을 해두면, UsernamePasswordAuthenticationFilter가 POST /login 요청을 낚아채서, 자동으로 UserDetailService 타입으로 IoC 되어있는 loadByUsername 함수가 실행된다.
- 시큐리티가 loadUserByUsername을 실행해서 DB에서 유저를 찾고, PrincipalDetails로 포장해서 리턴한다.
- 시큐리티는 리턴 받은 PrincipalDetails 안의 비밀번호와, 사용자가 화면에서 입력한 비밀번호를 비교한다.
- 비밀번호가 일치하면 Authentication 객체를 만들어서 SecurityContextHolder(세션)에 저장한다.
- 이때, 함수 파라미터인 username은 로그인할 때 넘겨오는 파라미터 이름과 동일해야 한다.
- 즉, 로그인할 때 파라미터 이름을 username으로 해야 제대로 loadUserByUsername에 파라미터가 전달된다.
- 만약 username대신 email이나 userId 등으로 로그인하고 싶다면 SecurityConfig의 .formLogin() 안에 .usernameParameter("email") 등의 설정을 추가해주면 된다.
OAuth2.0
Oauth2.0 관련 application.yml 설정
spring:
security:
oauth2:
client:
provider:
naver:
authorization-uri: https://nid.naver.com/oauth2.0/authorize
token-uri: https://nid.naver.com/oauth2.0/token
user-info-uri: https://openapi.naver.com/v1/nid/me
user-name-attribute: response # 회원정보가 json으로 받는데, response 키 값으로 감싸져서 옴
registration:
google:
client-id: ${GOOGLE_CLIENT_ID}
client-secret: ${GOOGLE_CLIENT_SECRET}
scope:
- email
- profile
facebook:
client-id: ${FACEBOOK_CLIENT_ID}
client-secret: ${FACEBOOK_CLIENT_SECRET}
scope:
- email
- public_profile
naver:
client-id: ${NAVER_CLIENT_ID}
client-secret: ${NAVER_CLIENT_SECRET}
scope:
- name
- email
client-name: Naver
authorization-grant-type: authorization_code # code 방식
# 네이버는 기본적으로 스프링이 provider로 제공하지 않기에 직접 적어주어야 함. 구글, 페이스북 등은 기본 제공해서 안적어도 OK
redirect-uri: http://localhost:8080/login/oauth2/code/naver
PrincipalOauth2UserService
@Slf4j
@Service
@RequiredArgsConstructor
public class PrincipalOauth2UserService extends DefaultOAuth2UserService {
private final UserRepository userRepository;
private final BCryptPasswordEncoder bCryptPasswordEncoder;
// 구글 등 외부 사이트로부터 받은 userRequest 데이터에 대한 후처리 되는 함수
// 함수 종료 시 @AuthenticationPrincipal 어노테이션이 만들어짐
@Transactional // 추가해줌
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
// 구글 로그인 -> code 리턴(OAuth-Client 라이브러리) -> AccessToken 요청
// userRequest 정보 -> 회원 프로필 받기 위해 loadUser 함수 호출 -> 회원 프로필
OAuth2User oAuth2User = super.loadUser(userRequest);
// registrationId로 어떤 OAuth로 로그인했는지 확인 가능
log.info(userRequest.getClientRegistration().toString());
log.info(userRequest.getAccessToken().getTokenValue());
log.info(oAuth2User.getAttributes().toString());
// 회원가입 연동 진행
User user = registerUser(userRequest, oAuth2User);
return new PrincipalDetails(user, oAuth2User.getAttributes());
}
private User registerUser(OAuth2UserRequest userRequest, OAuth2User oAuth2User) {
OAuth2UserInfo oAuth2UserInfo = null;
if (userRequest.getClientRegistration().getRegistrationId().equals("google")) {
log.info("google login request");
oAuth2UserInfo = new GoogleUserInfo(oAuth2User.getAttributes());
} else if (userRequest.getClientRegistration().getRegistrationId().equals("facebook")) {
log.info("facebook login request");
oAuth2UserInfo = new FacebookUserInfo(oAuth2User.getAttributes());
} else if (userRequest.getClientRegistration().getRegistrationId().equals("naver")) {
log.info("naver login request");
oAuth2UserInfo = new NaverUserInfo((Map<String, Object>) oAuth2User.getAttributes().get("response"));
} else {
log.warn("only support google and facebook!");
}
String provider = oAuth2UserInfo.getProvider(); // google, facebook etc..
String providerId = oAuth2UserInfo.getProviderId();
String username = provider + '_' + providerId; // google_12421528902251...
String password = bCryptPasswordEncoder.encode("겟인데어"); // 의미 없는데 그냥 넣어는 주기 위해
String email = oAuth2UserInfo.getEmail();
String role = "ROLE_USER";
User user = userRepository.findByUsername(username);
if (user == null) {
user = User.builder()
.username(username)
.password(password)
.email(email)
.role(role)
.provider(provider)
.providerId(providerId)
.build();
userRepository.save(user);
} else {
log.info("이미 가입된 회원이 있습니다!");
}
return user;
}
}
- 구글이나 네이버 등에서 로그인을 마치면, 우리 서버로 GET /login/oauth2/code/{provider} 콜백 요청이 들어온다.
- 그러면 OAuth2LoginAuthenticationFilter가 요청을 낚아채 엑세스 토큰을 받고, 시큐리티 설정(.userInfoEndpoint)에 등록해둔 커스텀 서비스(DefaultOAuth2UserService 상속 클래스)의 loadUser() 함수를 자동으로 호출한다.
- 따라서 loadUser() 함수에서 userRequest 정보(이메일, 이름 등)를 바탕으로 자동 회원가입 시켜주고, 최종적으로 커스텀 PrincipalDetails 객체로 (일반 로그인과 통일된) 포장해서 반환해주기만 하면 된다.
OAuth2UserInfo
public interface OAuth2UserInfo {
String getProviderId();
String getProvider();
String getEmail();
String getName();
}
- 그리고 각 provider 별로 providerId에 접근하는 key 값이 다르다.
- 따라서 provider 별로 해당 인터페이스를 구현한 클래스를 사용하면, 모든 provider에 대해 해당 인터페이스 타입으로 추상화해서 사용할 수 있다.
JWT를 이해하기 위한 배경지식
OSI 7계층에서의 데이터 전송 흐름
- 결국 모든 데이터는 네트워크의 OSI 7 계층 안에서 전송되므로, 사용자 로그인 정보도 이 안에서 돌고 돈다.
TCP/IP 3-Way Handshake 통신
- 우선, 웹 서비스는 데이터의 유실이 없어야 하므로 신뢰성이 보장되는 TCP를 기반으로 통신한다.
- 전송 계층 (4계층) 에서는, SYN (Client -> Server 통신 여부 확인), SYN-ACK (Server -> Client 통신 가능 응답), ACK (Client -> Server SYN-ACK에 대한 최종 수신 확인 응답) 의 TCP 3-Way Handshake로 연결을 수립한다.
통신 규약(HTTP)과 데이터의 하향 추상화
- 연결이 수립되면, 클라이언트는 서버가 알아볼 수 있는 문법인 HTTP 프로토콜에 맞춰 HTTP 요청 메시지를 작성한다.
- 메시지는 아래 계층으로 내려가면서 추상화(캡슐화) 과정을 거친다.
- 4계층의 TCP에 의해 여러 개의 세그먼트(Segment)로 잘게 쪼개지고, 3계층에서 IP 주소가 붙어 패킷이 된 후 서버로 전송된다.
- 서버는 이 포장을 역으로 벗겨내어(디캡슐레이션) 원래의 HTTP 요청 메시지를 읽고 비즈니스 로직(WAS 처리)을 수행한다.
딜레마: 연결지향(TCP) 위에서 도는 무상태성(HTTP)
- WAS가 처리를 마치면 응답 코드와 데이터를 담은 HTTP 응답 메시지를 다시 쪼개어 클라이언트에게 돌려보낸다.
- 이때, 하부의 TCP 자체는 연결을 유지하려는 성질(Stateful)을 가지지만, 그 위에서 웹 통신을 관장하는 HTTP는 무상태(Stateless)로 설계되었다.
- 즉, HTTP는 한 번의 요청과 응답 사이클이 끝나면 과거의 요청을 기억할 수 없다.
- 방금 응답을 받은 클라이언트가 1초 뒤에 다시 요청을 보내도, 서버의 HTTP는 이전과 동일한 클라이언트라는 사실을 인지할 수 없다.
로그인(상태 유지)을 위한 극복 방안: 세션과 쿠키
- 하지만 웹 서비스는 유저의 로그인 상태를 기억해야 한다. 매 요청마다 로그인을 시킬 수 없으니 말이다.
- HTTP의 무상태성을 극복하기 위해 등장한 것이 세션과 쿠키다.
- 사용자가 로그인을 요청하면, 서버는 이 사용자가 인증되었음을 기억하기 위해 WAS의 메모리에 세션을 만들고 고유한 세션 ID를 발급한다.
- 그리고 HTTP 응답을 보낼 때, 헤더에 Set-Cookie: JSESSIONID=... 형태로 이 신분증을 끼워 보낸다.
- 이후 클라이언트는 새로운 요청을 보낼 때마다 무조건 HTTP 헤더에 이 쿠키(세션 ID)를 담아 보냄으로써, 무상태 환경 속에서도 서버에게 이미 로그인이 완료된 클라이언트 요청임을 알리도록 한다.
RSA
- 네트워크를 통해 데이터를 주고받을 때, 정보보안의 핵심 CIA(기밀성, 무결성, 가용성)를 지키는 것은 매우 중요하다.
- 데이터를 안전하게 보내기 위해 하나의 비밀번호(대칭키)로 데이터를 암호화(기밀성 유지)할 수 있다.
- 하지만 수신측이 이를 해독하려면 이 대칭키를 같이 전송해 주어야 하는데, 중간에 해커가 이 키를 탈취하면 기밀성, 무결성이 모두 깨진다.
- 그래서 비대칭키(공개키) 암호화 알고리즘인 RSA가 사용된다.
- 송신측은 데이터를 보낼 때, 수신측의 공개키로 데이터를 잠그고 자신의 개인키(전자 서명)로 한 번 더 잠근다.
- 그러면 수신측에서 송신측의 공개키로 송신자를 인증할 수 있으며(최초 송신자 확인), -> 무결성 확보
- 데이터 복호화에 필요한 개인키는 데이터 전송할 때 보내지 않으므로 탈취될 우려가 없으므로, 수신측은 자신의 개인키로 데이터를 안전하게 복호화 할 수 있다. -> 기밀성 확보
- 하지만 JWT는 성능상의 이유로, 데이터를 공개키로 암호화 하지 않고 (기밀성 포기) 송신측의 개인키로 데이터의 해시값만 잠그는 전자 서명 기능만 사용해서 데이터의 위변조 방지(무결성)와 발급자 인증을 확보한다.
RFC (Request For Comments)
- 전 세계 개발자들이 다 함께 약속한 인터넷 표준 규칙서이다.
- RFC 7519: 수많은 규칙 문서 중, JWT(JSON Web Token)를 어떻게 만들고 사용할지 정의해 둔 문서 번호이다.
Json Web Token
JWT 구성 정보
- 서버는 Header, Payload, Signature 값을 생성하고 이들을 담은 JWT를 만들어서 클라이언트에게 전달한다.
- Header에는 어떤 알고리즘을 사용해서 서명을 했는지에 대한 정보가,
- Payload에는 유저의 식별 정보(ID, 권한 등)가,
- Signature에는 (Header + Payload + Secret Key) 값을 단방향 해싱(SHA256)한 값이 저장된다.
- Secret Key는 서버의 값이므로, 서명 검증은 이 서버의 Secret Key 값을 통해 이루어진다.
JWT의 철학: 기밀성이 아닌 무결성
- 각 값들은 Base64Url로 인코딩 되어있으며, Base64Url이기 때문에 원본 데이터로 디코딩이 가능하다.
- 즉, 데이터의 기밀성이 중요한 것이 아니라 누구의 서명인지에 대한 무결성을 중요시하는 것이 JWT의 철학이다.
JWT 인증 방식
- 이렇게 서버가 클라이언트에 전달해준 JWT는 웹브라우저의 로컬 스토리지나 쿠키 등에 저장되며, 서버로 요청을 보낼 때 JWT를 보낸다.
- 서버는 해당 토큰이 유효한지를 검증할 때, (Header + Payload + Secret Key) 를 SHA256으로 해싱 해봐서 Signature와 동일한지를 비교함으로써 토큰을 검증한다.
- 즉 클라이언트의 토큰이 유효하다면 해당 토큰은 서버의 Secret Key 값으로 해싱 되었다는 소리이고, 그렇지 않다면 중간에 데이터가 수정되어 무결성이 깨졌다는 것을 의미한다.
- 따라서 세션을 사용하지 않고 서명 검증을 통해 사용자를 인증하므로, 서버는 세션에서 사용자 정보를 확인할 필요가 없다.
- HS256 알고리즘을 사용한다면 서버는 자신의 Secret Key만 알고 있으면 된다.
- RSA 같은 비대칭키 알고리즘을 사용한다면, 토큰을 발급하는 인증 서버는 개인키를 보관하여 서명에 사용하고, 토큰을 검증해야 하는 일반 API 서버들은 공개키만 가지고 무결성을 확인하면 된다.
SecurityConfig
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final CorsFilter corsFilter;
private final AuthenticationConfiguration authenticationConfiguration;
private final UserRepository userRepository;
private final JwtProperties jwtProperties;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
// // 스프링 시큐리티 필터가 적용되기 전에 적용되는 커스텀 필터. 이렇게도 된다는걸 보여주기 위함
// http.addFilterBefore(new MyFilter3(jwtProperties.getSecret()), BasicAuthenticationFilter.class);
/**
* csrf 해제: 세션을 안쓰니깐 방어 필요 없음
* CSRF = 크로스 사이트 요청 위조
* 세션 방식은, 요청마다 무조건 자동으로 쿠키(세션 ID)가 설정(Set-Cookie: JSESSIONID=)됨.
* 이를 노려서 세션 쿠키를 발급받고, 잘못된 API 요청 (100만원 송금 등)을 보내게 유도하는 것
* JWT는 토큰을 쿠키로 자동으로 주지 않고, 프론트엔드 개발자가 직접 브라우저에서 꺼내서 보내야 함
*/
http.csrf(csrf -> csrf.disable());
// JWT 기반이므로 무상태로 -> 세션을 안쓰겠다는 소리
http.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
);
// @CrossOrigin(인증X), 시큐리티 필터에 등록 인증 (O)
// jwt는 토큰 없이 보내면 컨트롤러에 못가게 컷트 하므로 cors 에러가 떠버리므로 필터 추가
// 즉, 인증은 실패해도 cors는 허용되었다는 것을 알려주기 위함
http.addFilter(corsFilter);
/**
* 1. formLogin 비활성화 이유
* - 세션 기반에서는 스프링 시큐리티가 기본 로그인 페이지를 만들고,
* 폼 데이터(x-www-form-urlencoded)로 오는 요청을 가로채서 처리했음.
* - 하지만 우리는 REST API 서버이므로 클라이언트가 JSON 포맷으로 ID/PW를 보낼 것임.
* formLogin은 JSON 포맷을 이해하지 못하므로 비활성화.
*
* 2. 세션/쿠키 방식의 확장성 문제
* - 쿠키는 기본적으로 동일한 도메인에서만 동작함.
* - 프론트엔드와 백엔드의 도메인이 다르거나, MSA 환경처럼 서버 도메인이 여러 개로 쪼개지면
* 크로스 도메인 환경에서 세션 쿠키를 공유하고 전달하기가 매우 까다로워짐 (CORS 및 SameSite 이슈).
*
* 3. httpBasic 비활성화 이유
* - 쿠키 문제를 피하기 위해, 매 API 요청마다 헤더(Authorization)에 ID와 PW를
* 직접 달아서 보내는 방식이 HTTP Basic 인증 방식임.
* - 쿠키를 안 쓰니 확장성은 좋지만, 매 요청마다 ID/PW가 네트워크를 타고 날아가므로
* 중간에 패킷이 털리면 치명적임. (반드시 HTTPS를 강제해야 함)
*
* 4. 결론: Bearer (JWT) 방식 사용
* - 매번 ID/PW를 보내는 Basic 방식의 위험성을 피하기 위해,
* 최초 로그인 시에만 ID/PW를 보내고, 서버가 증명서(JWT 토큰)를 발급해 줌.
* - 이후부터는 헤더(Authorization)에 ID/PW 대신 토큰을 담아서 보내는 Bearer 방식을 사용!
* - 따라서 이 Bearer(JWT) 방식을 적용하기 위해 낡은 formLogin과 httpBasic 설정을 모두 끄는 것임.
*/
http.formLogin(form -> form.disable());
http.httpBasic(basic -> basic.disable());
// jwt 로그인을 위한 필터
http.addFilter(new JwtAuthenticationFilter(
jwtProperties,
authenticationConfiguration.getAuthenticationManager())
);
http.addFilter(new JwtAuthorizationFilter(
jwtProperties,
userRepository,
authenticationConfiguration.getAuthenticationManager())
);
// 권한 처리
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/join").permitAll()
.requestMatchers("/api/v1/user/**").hasAnyRole("USER", "MANAGER", "ADMIN")
.requestMatchers("/api/v1/manager/**").hasAnyRole("MANAGER", "ADMIN")
.requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
.anyRequest().authenticated() // 다른 요청들은 인증만
);
return http.build();
}
}
- 세션을 사용하지 않기 때문에 CSRF 보호를 활성화할 필요가 없다.
- formLogin과 httpBasic을 비활성화 해준다. 이유는 위 주석에 나타나 있다.
JwtProperties
@Getter
@Setter
@Component
@ConfigurationProperties(prefix = "jwt") // yml에서 jwt로 시작하는 상수 찾아서 자동 주입
public class JwtProperties {
private String secret;
private int expireTime = 60000 * 10;
private String tokenPrefix = "Bearer ";
private String headerString = "Authorization";
}
- JWT의 시크릿 키나 만료 시간 등은 하드코딩하지 않고 객체로 관리하는 것이 객체지향적이다.
- @ConfigurationProperties를 활용해 application.yml의 설정값을 깔끔하게 주입받는다.
JwtAuthenticationFilter
@Slf4j
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
private final JwtProperties jwtProperties;
public JwtAuthenticationFilter(JwtProperties jwtProperties, AuthenticationManager authenticationManager) {
super(authenticationManager);
this.jwtProperties = jwtProperties;
}
// /login 요청을 하면 로그인 시도를 위해 실행되는 함수
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
try {
ObjectMapper om = new ObjectMapper();
User user = om.readValue(request.getInputStream(), User.class);
log.info("User: {}", user.toString());
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(user.getUsername(), user.getPassword());
/**
* id, password로 로그인 시도
* authenticationManager로 로그인 시도 하면 PrincipalDetailsService의 loadUserByUsername 호출됨
* PrincipalDetails를 세션에 담고 (권한 관리를 위해서) JWT 토큰을 만들어서 반환해주면 됨.
*/
// PrincipalDetailsService의 loadUserByUsername() 실행됨 -> 즉 토큰을 이용해 로그인 시도해보는 것
// 제대로 실행되면 로그인이 성공한 것. 이 객체에는 로그인한 유저의 정보가 담기고 스프링 시큐리티 세션에 저장됨
Authentication authentication =
this.getAuthenticationManager().authenticate(authenticationToken);
// 리턴해주는 이유는 권한 관리를 spring security가 대신 해주기 때문에 편하기 때문
// 굳이 JWT 토큰을 사용하면서 세션을 만들 이유가 X. 권한 때문에 세션에 넣어주는 것
return authentication;
} catch (IOException e) {
throw new RuntimeException(e);
}
}
// 위의 attemptAuthentication 실행 후 인증이 정상적으로 되면 이 함수가 다음에 실행됨
// 그러면 이 함수에서 JWT 토큰 만들어서 request 요청한 사용자에게 전달해주면 됨
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
log.info("로그인 완료되고 successfulAuthentication 호출");
PrincipalDetails principal = (PrincipalDetails) authResult.getPrincipal();
// RSA는 아니고 HASH 암호 방식.
String jwtToken = JWT.create()
.withSubject(principal.getUsername())
.withExpiresAt(new Date(System.currentTimeMillis() + jwtProperties.getExpireTime())) // 10분 유효시간
.withClaim("id", principal.getUser().getId())
.withClaim("username", principal.getUsername())
.sign(Algorithm.HMAC512(jwtProperties.getSecret()));
// 이제 요청한 사용자 응답 헤더에 이 jwt 토큰 값이 Authorization 헤더에 담기게 됨.
// 그럼 프론트에서 이 값을 브라우저 로컬 스토리지에 저장시켜놔서 요청마다 보내면 됨
response.addHeader(jwtProperties.getHeaderString(), jwtProperties.getTokenPrefix() + jwtToken);
}
}
- 스프링 시큐리티의 UsernamePasswordAuthenticationFilter를 상속받아, /login 요청을 낚아채 JWT를 생성하는 필터
- 원래는 /login 요청이 오면 UsernamePasswordAuthenticationFilter 객체를 상속한 해당 클래스의 attemptAuthentication()가 호출된다.
- 근데 formLogin 비활성화해서 자동으로 동작을 안하므로, 수동으로 addFilter로 추가해준 것.
- POST /login 요청이 오면, attemptAuthentication이 이를 낚아채서 ID/PW를 추출해 임시 토큰UsernamePasswordAuthenticationToken을 만든다.
- 이 임시 토큰을 AuthenticationManager에 넘겨서 로그인을 처리한다. 매니저는 UserDetailsService 클래스를 상속한 PrincipalDetailService 클래스의 loadUserByUsername()을 호출해서 DB에 저장된 유저 정보를 가져온다.
- 이 때 DB에 저장된 유저의 비밀번호 해시값과 로그인 요청 비밀번호를 내부적으로 비교해서 로그인을 진행한다.
- 로그인이 성공하면 스프링 시큐리티가 알아서 succesfulAuthentication()을 호출해준다. 여기서 JWT 토큰을 만들어서 응답 헤더에 넣어주면 된다.
JwtAuthorizationFilter
@Slf4j
public class JwtAuthorizationFilter extends BasicAuthenticationFilter {
private final JwtProperties jwtProperties;
private final UserRepository userRepository;
public JwtAuthorizationFilter(JwtProperties jwtProperties, UserRepository userRepository, AuthenticationManager authenticationManager) {
super(authenticationManager);
this.jwtProperties = jwtProperties;
this.userRepository = userRepository;
}
// 인증이나 권한이 필요한 API 요청은 이 필터를 탐
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
log.info("인증이나 권한이 필요한 요청이 옴");
String jwtHeader = request.getHeader(jwtProperties.getHeaderString());
log.info("JWT Header: {}", jwtHeader);
// 헤더가 있는지 확인
if (jwtHeader == null || !jwtHeader.startsWith(jwtProperties.getTokenPrefix())) {
chain.doFilter(request, response);
return;
}
// JWT 토큰 검증. Bearer는 제거하고 가져오기
String token = request
.getHeader(jwtProperties.getHeaderString())
.replace(jwtProperties.getTokenPrefix(), "");
try {
String username = JWT
.require(Algorithm.HMAC512(jwtProperties.getSecret())).build()
.verify(token)
.getClaim("username")
.asString();
// 서명이 정상적으로 인증이 됨
if (username != null) {
User user = userRepository.findByUsername(username);
PrincipalDetails principalDetails = new PrincipalDetails(user);
// JWT 토큰 서명을 통해 서명이 정상이면 Authentication 객체를 만들어줌
Authentication authentication =
new UsernamePasswordAuthenticationToken(principalDetails, null, principalDetails.getAuthorities());
// 스프링 시큐리티 세션에 직접 Authentication 객체를 저장해줌
SecurityContextHolder.getContext().setAuthentication(authentication);
}
} catch (Exception e) {
// 토큰 만료, 위조 등의 예외 발생 시 서버가 죽지 않도록 Catch
// 로그만 남기고 빈 세션으로 통과시켜, 이후 권한 필터에서 401/403 에러로 처리하게 유도함
log.error("JWT 토큰 검증 실패: {}", e.getMessage());
}
// 예외가 터지든 정상 검증되든 필터는 무조건 다음으로 넘김
chain.doFilter(request, response);
}
}
- 프론트엔드가 권한이 필요한 API를 요청할 때마다 헤더에 담아 보내는 JWT의 유효성을 검사하는 필터
- 스프링 시큐리티의 BasicAuthenticationFilter를 상속받아 구현하며, 모든 HTTP 요청에 대해 doFilterInternal()이 동작하여 토큰을 검사한다.
- 요청이 들어오면 Authorization 헤더의 JWT 존재 여부와 형식을 확인하여 헤더를 검사한다.
- 토큰이 없는 경우: 토큰이 없다면 에러를 발생시키지 않고 chain.doFilter()를 통해 다음 필터로 요청을 그대로 통과
- 이때 시큐리티 세션(SecurityContext)에는 인증 객체가 없는 빈 상태가 됨.
- 이후 스프링 시큐리티의 권한 검사소(AuthorizationFilter)에 도달했을 때, 해당 요청 주소가 권한을 요구하는 주소라면 빈 세션을 확인하고 에러를 발생시켜 접근을 차단시킴.
- 권한이 필요 없는 주소라면 정상 통과됨. (/join 등)
- 토큰이 있는 경우: 서버의 시크릿 키로 서명(Signature)을 검증하여 무결성을 확인함.
- 일회성 강제 로그인 처리와 무상태성 유지
- 서명 검증이 완료되면, 해당 유저의 정보를 담은 PrincipalDetails와 UsernamePasswordAuthenticationToken을 생성하여 스프링 시큐리티의 보관소 SecurityContextHolder에 강제로 주입한다.
- 왜 JWT인데 컨텍스트에 저장하는가? 스프링 시큐리티의 권한 검사소(AuthorizationFilter)와 컨트롤러 단의 @AuthenticationPrincipal은 오직 SecurityContextHolder 내부의 인증 객체만 바라보고 동작하도록 설계되어 있기 때문이다. 즉, 이들을 정상적으로 통과하기 위한 임시 출입증을 달아주는 것.
- 무상태성의 유지: 여기서 주의할 점은, 이 정보가 전통적인 웹 세션(HttpSession)처럼 서버의 메모리에 영구적으로 저장되는 것이 아니라는 점이다. SecurityContextHolder는 ThreadLocal을 기반으로 동작하므로, 단일 HTTP 요청을 처리하는 순간에만 존재했다가 응답이 끝나면 파기된다. 이를 통해 서버는 무상태를 유지하면서도, 스프링 시큐리티의 권한 인가 생태계를 활용할 수 있다.
'공부 > Spring' 카테고리의 다른 글
| [인프런 김영한] 실전 데이터베이스 설계 1 (0) | 2026.03.29 |
|---|---|
| 재고 차감으로 알아보는 동시성 제어 - DB, Redis 락 (0) | 2026.02.21 |
| [인프런 김영한] 스프링 트랜잭션 AOP, 커밋/롤백, 전파 (0) | 2026.02.18 |
| [인프런 김영한] 자바 예외 (0) | 2026.02.08 |
| [인프런 김영한] 스프링과 트랜잭션 (0) | 2026.02.05 |
