스프링 시큐리티
- 막강한 인증(Authentication)과 인가(Authorization 혹은 권한 부여 기능)을 가진 프레임워크
- 스프링 가반 어플리케이션에서의 보안을 위한 표준
- 확장성을 고려한 프레임워크다보니 다양한 요구사항을 손쉽게 추가하고 변경할 수 있음
소셜 로그인을 사용하는 이유
직접 구현해야할 것이 매우 많음(배보다 배꼽이 커지는 경우), 아래 목록을 소셜 로그인을 제공하는 업체에 위임하여 개발자가 서비스 개발에 집중할 수 있음
직접 구현해야하는 목록
* 로그인 시 보안
* 회원가입 시 이메일 혹은 전화번호 인증
* 비밀번호 찾기
* 비밀번호 변경
* 회원정보 변경
OAuth란?
: 제 3의 서비스에 계정 관리를 맡기는 방식
OAuth 방식의 참여자들
- 리소스 오너(Resource Owner): 인증 서버에 자신의 정보를 사용하도록 허가하는 주체
- 서비스를 이용하는 사용자
- 리소스 서버(Resource Server): 리소스 오너의 정보를 가지며 리소스 오너의 정보를 보호하는 주체
- 네이버, 구글, 카카오 등
- 인증 서버(Authorization Server): 클라이언트에게 리소스 오너의 정보에 접근할 수 있는 토큰을 발급하는 역할을 하는 어플리케이션
- 클라이언트 어플리케이션(Client Application): 인증 서버에게 인증을 받고 리소스 오너의 리소슬르 사용하는 주체
- 개발중인 서비스, OAuth방식을 사용하고자하는 서비스(주체)
클라이언트가 리소스 오너 정보를 취득할 수있는 4가지 방법
- 권한 부여 코드 승인 타입: OAuth 2.0에서 가장 잘 알려진 인증 방법 클라이언트가 리소스에 접근하는데 사용하며 권한에 접근할 수 있는 코드와 리소스 오너에 대한 액세스 토큰을 발급받는 방식
- 암시적 승인 타입: 서버가 없는 자바스크립트 웹 애플리케이션 클라이언트에서 주로 사용하는 방법 클라이언트가 요청을 보내면 리소스 오너의 인증 과정 이외에는 권한 코드 교환 등의 별다른 인증 과정을 거치지 않고 액세스 토큰을 제공받는 방식
- 리소스 소유자 암호 자격 증명 승인타입: 클라이언트의 패스워드를 이용해서 엑세스 토큰에 대한 사용자의 자격 증명을 교환하는 방식
- 클라이언트 자격증명 승인 타입: 클라이언트가 컨텍스트 외부에서 액세스 토큰을 얻어 특정 리소스에 접근을 요청하는 방식
권한 부여 코드 승인 타입이란?
: OAuth2.0에서 제일 흔히 쓰이고 잘 알려진 방식
어떤 순서로 인증이 일어나는가?
- 권한 요청
- 데이터 접근용 권한 부여
- 인증 코드 발급
- 액세스 토큰으로 발급
- 액세스 토큰으로 데이터에 접근
우리 책에 나와있는 예시 코드도, 내가 설명하고자하는 구현 방버법도 다 이 권한 부여 코드 승인 타입을 다르고 있다.
우리 책의 코드 살펴보기
책: 스프링 부트와 AWS로 혼자 구현하는 웹 서비스 (이동욱 저)
OAuth2 + 세션 기반 인증을 바탕으로 로그인을 구현하고 있다.
우리 책의 전체적인 인증 과정 (Session 기반 인증)
- 사용자 로그인 요청: 사용자가 "구글로 로그인" 버튼을 클릭하면 브라우저는 OAuth2 제공자로 리다이렉트됨.
- OAuth2 인증: 사용자는 OAuth2 제공자(예: 구글)에서 로그인을 진행하고, 인증이 완료되면 어플리케이션으로 다시 리다이렉트됨. 이때 제공자는 인증 코드를 어플리케이션에 전달.
- 토큰 교환: 어플리케이션은 인증 코드를 액세스 토큰으로 교환하고, 그 토큰을 이용해 제공자로부터 사용자 정보를 가져옴
- 세션 생성: CustomOAuth2UserService는 사용자 정보를 처리한 후, 이를 SessionUser 객체에 저장하고, 이 객체를 HttpSession에 저장하여 세션 기반의 인증 상태를 유지
- 세션 관리: 어플리케이션은 HttpSession을 사용하여 여러 요청 간에 사용자의 인증 상태를 유지. 사용자가 페이지를 이동할 때마다 매번 다시 인증할 필요가 없음
SecurityConfig.java
@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfig {
// CustomOAuth2UserService를 주입받아 OAuth2 사용자 정보를 처리
private final CustomOAuth2UserService customOAuth2UserService;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.headers(headers -> headers.frameOptions(frameOptions -> frameOptions.disable()))
.authorizeHttpRequests(authz -> authz
.requestMatchers("/", "/css/**", "/images/**", "/js/**", "/h2-console/**", "/profile").permitAll()
.requestMatchers("/api/v1/**").hasRole(Role.USER.name())
.anyRequest().authenticated())
.logout(logout -> logout
.logoutSuccessUrl("/"))
// OAuth2 로그인 설정: 로그인 성공 후 사용자 정보를 처리할 customOAuth2UserService 설정
**.oauth2Login(oauth2 -> oauth2
.userInfoEndpoint(userInfo -> userInfo
.userService(customOAuth2UserService)));**
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
CustomOAuthUserService.java
@RequiredArgsConstructor
@Service
public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
// 사용자 정보를 저장하기 위한 UserRepository와 세션을 관리하는 HttpSession 주입
private final UserRepository userRepository;
private final HttpSession httpSession;
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2UserService delegate = new DefaultOAuth2UserService();
OAuth2User oAuth2User = delegate.loadUser(userRequest);
String registrationId = userRequest.getClientRegistration().getRegistrationId();
String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails()
.getUserInfoEndpoint().getUserNameAttributeName();
// OAuth 제공자로부터 받은 사용자 정보를 OAuthAttributes 객체로 변환
OAuthAttributes attributes = OAuthAttributes.of(registrationId, userNameAttributeName, oAuth2User.getAttributes());
// 사용자 정보를 데이터베이스에 저장하거나 업데이트
User user = saveOrUpdate(attributes);
httpSession.setAttribute("user", new SessionUser(user));
return new DefaultOAuth2User(
Collections.singleton(new SimpleGrantedAuthority(user.getRoleKey())),
attributes.getAttributes(),
attributes.getNameAttributeKey());
}
// 세션에 사용자 정보를 저장 (SessionUser 객체로 저장하여 직렬화 처리)
private User saveOrUpdate(OAuthAttributes attributes) {
User user = userRepository.findByEmail(attributes.getEmail())
.map(entity -> entity.update(attributes.getName(), attributes.getPicture()))
.orElse(attributes.toEntity());
return userRepository.save(user);
}
}
Session 인증
HTTP 프로코톨 자체는 Statelss 바식으로 매 요청마다 사용자 상태 정보를 전송해야하지만 세션 인증을 사용하면 서버가 사용자의 상태를 유지한다.
사용자가 로그인하면 세션을 생성하고, 이 세션을 통해 로그인 상태를 일정 시간동안 유지한다. 세션이 유지되는 동안에는 재로그인하지 않고도 계속해서 인증된 상태를 유지할 수 있다. 세션은 설정된 만료 시간에 따라 자동으로 종료되거나 사용자가 로그아웃할 때 삭제된다. 이렇게 세션이 만료된 다시 로그인하는 절차가 필요하다.
세션 데이터는 서버 측에서 유지되므로, 서버 자원을 소모한다. 특히 다수의 사용자가 동시에 접속할 때 서버의 메모리나 스토리지 사용량이 증가할 수 있다. 따라서 큰 규모의 애플리케이션에서는 서버 확장성과 부하 관리가 중요한 문제점이 될 수 있다.
JWT + OAuth2를 사용한 로그인 구현
이 둘이 함께 사용되면 세션 대신 JWT가 인증을 유지하는 역할을 한다고 보면된다. Access Token은 API 요청 시 사용되고, Refresh Token은 만료된 Access Token을 재발급하는 데 사용된다. Refresh Token은 보안상 쿠키에 저장되어 서버와의 지속적인 인증을 보장한다.
장점
- 서버에 상태를 저장할 필요 없이 클라이언트 측에서 토큰을 통해 이증 상태를 유지할 수 있어서버 확장성에 유리하다.
- Refresh Token을 통해 Access Token이 만료되었을 때 재발급이 가능하므로, 사용자는 로그아웃하지 않고도 지속적인 인증 상태를 유지할 수 있다.
- 분산된 환경에서도 JWT를 통해 인증 상태를 유지할 수 있어, 서버 간 세션 동기화가 필요하지 않으며 클라우드 환경에서 쉽게 확장할 수 있다.
JWT란?
JWT (JSON Web Token)는 JSON 형식으로 인코딩된 자격 증명을 사용하는 토큰 기반 인증 방식. 크게 헤더(Header), 페이로드(Payload), 서명(Signature)의 세 부분으로 구성됨.
보안을 위해서는 토큰의 유효기간이 짧은 것이 좋은데, 사용자의 입장에서는 불편하다. 이를 해결하기 위한 방법이 리프레시 토큰이다. 리프레시 토큰은 액세스 토큰이 만료되었을 때 새로운 액세스 토큰을 발급하기위한 토큰이다. 액세스 토큰을 짧게 설정하고 리프레시 토큰의 유효기간을 길게 설정하면 공격자가 액세스 토큰을 탈취해도 얼마지나지 않아서 사용할 수 없는 토큰이 될 것이다.
클라이언트는 JWT를 브라우저 쿠키나 로컬 스토리지에 저장하고, 이후 서버로 요청을 보낼 때마다 헤더에 JWT를 포함시켜서 보낸다.
개인적으로 준비한 코드 설명
1.OAuth2 인증 성공
- 사용자가 OAuth2 제공자(예: 구글)에서 인증을 완료하면, OAuth2UserCustomService가 사용자 정보를 가져온다. 이 클래스는 사용자가 로그인할 때 OAuth2 제공자로부터 반환된 정보를 사용하여 새로운 사용자 등록 또는 기존 사용자 업데이트를 처리한다.
@RequiredArgsConstructor
@Service
public class OAuth2UserCustomService extends DefaultOAuth2UserService {
private final UserRepository userRepository;
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2User user = super.loadUser(userRequest); //요청을 바탕으로 유저 정보를 담은 객체 반환
saveOrUpdate(user);
return user;
}
//유저가 있으면 업데이트, 없으면 유저 생성
private User saveOrUpdate(OAuth2User oAuth2User) {
Map<String, Object> attributes = oAuth2User.getAttributes();
String email = (String) attributes.get("email");
String name = (String) attributes.get("name");
User user = userRepository.findByEmail(email)
.map(entity -> entity.update(name))
.orElse(User.builder()
.email(email)
.nickname(name)
.build());
return userRepository.save(user);
}
}
2. JWT 발급
- OAuth2SuccessHandler에서 사용자가 인증되면, TokenProvider를 이용해 JWT(Access Token 및 Refresh Token)를 생성한다.
- Access Token: 짧은 유효 기간(예: 1일)으로 발급, 이 토큰을 통해 사용자의 인증 상태를 유지
- Refresh Token: 더 긴 유효 기간(예: 14일)을 가지며, Access Token이 만료되었을 때 새로 발급받기 위해 사용
@RequiredArgsConstructor
@Component
public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
// Refresh Token 관련 상수 설정
public static final String REFRESH_TOKEN_COOKIE_NAME = "refresh_token"; // 쿠키에 저장될 Refresh Token의 이름
public static final Duration REFRESH_TOKEN_DURATION = Duration.ofDays(14); // Refresh Token 유효 기간 (14일)
public static final Duration ACCESS_TOKEN_DURATION = Duration.ofDays(1); // Access Token 유효 기간 (1일)
public static final String REDIRECT_PATH = "/articles"; // 리다이렉션 경로
private final TokenProvider tokenProvider;
private final RefreshTokenRepository refreshTokenRepository;
private final OAuth2AuthorizationRequestBasedOnCookieRepository authorizationRequestRepository;
private final UserService userService;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {
// OAuth2 인증이 성공하면 사용자 정보를 가져옴
OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal();
User user = userService.findByEmail((String) oAuth2User.getAttributes().get("email"));
// Refresh Token 생성 후 저장 및 쿠키에 추가
String refreshToken = tokenProvider.generateToken(user, REFRESH_TOKEN_DURATION);
saveRefreshToken(user.getId(), refreshToken);
addRefreshTokenToCookie(request, response, refreshToken);
// Access Token 생성 후 리다이렉션 URL에 포함
String accessToken = tokenProvider.generateToken(user, ACCESS_TOKEN_DURATION);
String targetUrl = getTargetUrl(accessToken);
// 인증 관련 속성 제거
clearAuthenticationAttributes(request, response);
// 사용자를 지정된 URL로 리다이렉트
getRedirectStrategy().sendRedirect(request, response, targetUrl);
}
// Refresh Token을 저장 또는 업데이트
private void saveRefreshToken(Long userId, String newRefreshToken) {
RefreshToken refreshToken = refreshTokenRepository.findByUserId(userId)
.map(entity -> entity.update(newRefreshToken)) // 기존 토큰 업데이트
.orElse(new RefreshToken(userId, newRefreshToken)); // 새로 저장
refreshTokenRepository.save(refreshToken); // DB에 저장
}
// Refresh Token을 쿠키에 저장
private void addRefreshTokenToCookie(HttpServletRequest request, HttpServletResponse response, String refreshToken) {
int cookieMaxAge = (int) REFRESH_TOKEN_DURATION.toSeconds(); // 쿠키 유효 기간 설정
CookieUtil.deleteCookie(request, response, REFRESH_TOKEN_COOKIE_NAME); // 기존 쿠키 삭제
CookieUtil.addCookie(response, REFRESH_TOKEN_COOKIE_NAME, refreshToken, cookieMaxAge); // 새 쿠키 추가
}
// OAuth2 인증 관련 속성 제거 (보안 목적)
private void clearAuthenticationAttributes(HttpServletRequest request, HttpServletResponse response) {
super.clearAuthenticationAttributes(request); // 기본 인증 속성 제거
authorizationRequestRepository.removeAuthorizationRequestCookies(request, response); // 인증 요청 쿠키 제거
}
// 리다이렉트될 URL 생성, Access Token을 쿼리 파라미터로 포함
private String getTargetUrl(String token) {
return UriComponentsBuilder.fromUriString(REDIRECT_PATH)
.queryParam("token", token)
.build()
.toUriString();
}
}
@RequiredArgsConstructor
@Service
public class TokenProvider {
private final JwtProperties jwtProperties;
// 사용자의 정보를 바탕으로 JWT 토큰을 생성
public String generateToken(User user, Duration expiredAt) {
Date now = new Date();
return makeToken(new Date(now.getTime() + expiredAt.toMillis()), user);
}
// JWT 토큰을 생성하는 메서드 (만료일과 사용자 정보를 사용)
private String makeToken(Date expiry, User user) {
Date now = new Date();
return Jwts.builder()
.setHeaderParam(Header.TYPE, Header.JWT_TYPE) // JWT 헤더 설정
.setIssuer(jwtProperties.getIssuer()) // 발급자 설정
.setIssuedAt(now) // 토큰 발급 시간
.setExpiration(expiry) // 만료 시간
.setSubject(user.getEmail()) // 토큰의 주체 설정 (사용자 이메일)
.claim("id", user.getId()) // 사용자 ID를 클레임으로 추가
.signWith(SignatureAlgorithm.HS256, jwtProperties.getSecretKey()) // 서명 알고리즘 및 비밀키 설정
.compact(); // JWT 토큰 생성
}
// 주어진 토큰의 유효성을 검증
public boolean validToken(String token) {
try {
Jwts.parser()
.setSigningKey(jwtProperties.getSecretKey()) // 비밀키로 서명 검증
.parseClaimsJws(token); // 토큰을 파싱하여 검증
return true;
} catch (Exception e) {
return false; // 유효하지 않은 토큰일 경우 false 반환
}
}
// 토큰에서 인증 정보 추출
public Authentication getAuthentication(String token) {
Claims claims = getClaims(token); // 토큰에서 클레임을 추출
Set<SimpleGrantedAuthority> authorities = Collections.singleton(new SimpleGrantedAuthority("ROLE_USER"));
// 인증 객체를 생성하여 반환
return new UsernamePasswordAuthenticationToken(new org.springframework.security.core.userdetails.User(claims.getSubject
(), "", authorities), token, authorities);
}
// 토큰에서 사용자 ID 추출
public Long getUserId(String token) {
Claims claims = getClaims(token);
return claims.get("id", Long.class);
}
// 토큰에서 클레임(Claim) 정보를 가져오는 메서드
private Claims getClaims(String token) {
return Jwts.parser()
.setSigningKey(jwtProperties.getSecretKey()) // 서명 검증을 위한 비밀키 설정
.parseClaimsJws(token) // 토큰을 파싱하여 클레임을 가져옴
.getBody(); // 클레임 본문 반환
}
}
3. 쿠키에 Refresh Token 저장
- OAuth2SuccessHandler에서 쿠키에 Refresh Token을 저장. 이를 통해 클라이언트는 서버와의 세션을 지속할 수 있다. 클라이언트는 쿠키를 통해 서버와 통신하며, 만료된 Access Token을 Refresh Token으로 재발급받을 수 있다.
4. Access Token 전달 및 리다이렉트
- 사용자가 OAuth2 로그인을 성공하면, 서버는 Access Token을 URL 쿼리 파라미터로 전달하여 클라이언트를 지정된 경로로 리다이렉트한다. 클라이언트는 이 Access Token을 저장하고, 이후 서버로의 요청마다 헤더에 포함해 인증에 사용.
5. Access Token 사용
- 클라이언트는 Access Token을 포함하여 API 요청을 보낸다. 이 토큰은 서버 측에서 검증되며, 사용자의 인증 상태를 유지하는 데 사용된다. Access Token이 만료되면 Refresh Token을 사용해 새 Access Token을 발급받을 수 있다.
6. Refresh Token을 통한 Access Token 재발급
- 만료된 Access Token: Access Token이 만료되었을 경우, 클라이언트는 저장된 Refresh Token을 서버로 보내 새로운 Access Token을 요청한다. TokenService는 Refresh Token을 검증한 후 새로운 Access Token을 발급한다.
- @RequiredArgsConstructor @Service public class TokenService { private final TokenProvider tokenProvider; private final RefreshTokenService refreshTokenService; private final UserService userService; public String createNewAccessToken(String refreshToken) { // 토큰 유효성 검사에 실패하면 예외 발생 if(!tokenProvider.validToken(refreshToken)) { throw new IllegalArgumentException("Unexpected token"); } Long userId = refreshTokenService.findByRefreshToken(refreshToken).getUserId(); User user = userService.findById(userId); return tokenProvider.generateToken(user, Duration.ofHours(2)); } }
리프레시 토큰을 쿠키에 저장하는 이유: 보안과 편의성
- 자동 전송: 쿠키에 저장된 리프레시 토큰은 사용자가 서버로 요청을 보낼 때마다 자동으로 전송된다. 사용자는 리프레시 토큰을 직접 관리할 필요가 없다.
- 보안: 리프레시 토큰은 HTTPOnly와 Secure 속성을 통해 브라우저 스크립트에서 접근하지 못하도록 보호된다. 또한 HTTPS를 사용해 통신 중에 토큰이 탈취되는 것을 방지한다.이지원
- 막강한 인증(Authentication)과 인가(Authorization 혹은 권한 부여 기능)을 가진 프레임워크
- 스프링 가반 어플리케이션에서의 보안을 위한 표준
- 확장성을 고려한 프레임워크다보니 다양한 요구사항을 손쉽게 추가하고 변경할 수 있음