OAuth2.0
- 인증 및 권한 획득을 위한 업계 표준 프로토콜
- 보안수준이 어느정도 검증된 플랫폼의 API를 이용하여 사용자 인증과 리소스에 대한 권한 획득(인가)을 할 수 있도록 해준다.
- 즉, 구글, 카카오, 네이버 등과 같은 사이트에서 로그인을 하면 직접 구현한 사이트에서도 로그인 인증을 받을 수 있도록 하는 구조
구성요소
- Resource Owner: 개인 정보의 소유자
- Client: 리소스 서버에서 제공해주는 자원을 사용하는 외부 플랫폼
- Authorization Server: 외부 플랫폼이 리소스 서버의 사용자 자원을 사용하기 위한 인증 서버
- Resource Server: 사용자의 개인 정보(자원)을 저장하고 제공해주는 서버
인증 종류
1. 권한 코드 승인 방식
- 일반적으로 서버 사이드에서 인증을 처리할 때 이용하는 방식으로, Resource Owner로부터 리소스 사용에 대한 허락을 의미하는 Authorization Code를 이용하여 Access Token을 요청하는 방식
- Access Token이 바로 클라이언트로 전달되지 않기 때문에 다른 방식보다 보안에 좋은 특징
2. 암시적 승인 방식
- 권한 코드 없이 바로 토큰을 발급하여 사용하는 방식으로, 브라우저나 앱과 같은 서버와의 연동이 없는 애플리케이션에 주로 사용
- 권한 코드 검증이 들어가지 않기 때문에 보통 Read Only 서비스에서만 사용
3. 비밀번호 자격 증명 방식
- Client에 Server Provider(구글, 네이버 등)의 아이디와 비밀번호를 저장해두고 사용하는 방식
- 일반적으로 Client와 Server Provider의 관계가 아주 긴밀한 관계일 때만 사용
4. 클라이언트 자격 증명 방식
- Client와 Resource Owner가 같을 때 사용하는 인증 방식
- 추가적인 인증이 필요하지 않고, Authorization Server로부터 바로 토큰을 받을 수 있다.
Access Token
- 소셜 플랫폼에서 로그인을 했다 하더라도 개발한 웹 사이트에 ID와 PW를 그대로 전달해주면 안되기 때문에 Access Token을 발급 받고, 그 토큰을 기반으로 원하는 기능을 구현해야 한다.
- Access Token은 로그인 하지 않고도 인증을 할 수 있도록 해주는 인증 토큰으로, Access Token을 발급받기 위한 일련의 과정들을 인터페이스로 정의해둔 것이 OAuth
- 스프링 시큐리티에서는 별도의 설정이 없다면 세션을 이용하여 서버 측에 사용자 정보만을 저장하며, Access Token은 사용자 정보에 직접 접근할 수 있도록 해주는 정보만 가지고 있다.
- Refresh Token은 새로운 Access Token을 발급받기 위한 정보를 담고 있으며, 클라이언트가 Access Token이 없거나 만료된 상태라면, Refresh Token을 통해 Auth Server에 요청하여 새로운 Access Token을 발급받을 수 있다.
- 서버는 클라이언트의 요청이 들어올 때마다 Access Token이 유효한지 매번 클라이언트 상태를 관리 및 공유할 추가적인 저장 공간과, 요청마다 Access Token의 검증 및 업데이트를 위한 DB 호출이 발생한다.
- 마이크로 서비스 개발처럼 서버의 수가 많은 경우에는 각각의 서버가 Access Token의 유효성 및 권한 확인을 Auth Server에 요청하기 때문에 병목 현상 등이 발생해 서버의 부하로 이어질 수 있다.
- 이러한 문제점을 해결하기 위해 JWT 기반 인증 도입
로그인 기능 구현
의존성 추가
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
application-ouath.yml
spring:
security:
oauth2:
client:
registration:
google:
client-id: {클라이언트 ID}
client-secret: {클라이언트 보안 비밀}
scope: profile, email
naver:
client-id: {클라이언트 ID}
client-secret: {클라이언트 보안 비밀}
redirectUri: http://localhost:8181/login/oauth2/code/naver
authorization-grant-type: authorization_code
scope: name, email
client-name: Naver
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
- 스프링 부트에서 yml이나 properties의 이름을 application-xxx 형식으로 만들면 xxx라는 이름의 profile이 생성되어 이를 통해 관리할 수 있다.
- profile=xxx라는 식으로 호출하면 해당 yml이나 properties의 설정들을 가져올 수 있다.
spring:
profiles:
include: oauth
- 스프링 부트 2.0 방식에서는 url 주소를 모두 명시할 필요없이, client 인증 정보만 입력하면 된다.
- CommonOAuth2Provider라는 enum이 추가되어 구글, 깃허브, 페이스북, 옥타의 기본 설정값을 제공한다.
- 네이버는 스프링 시큐리티를 공식 지원하지 않기 때문에 CommonOAuth2Provider에서 지원하는 설정값들을 수동으로 입력해줘야 한다.
승인된 리다이렉션 URI
- 서비스에서 파라미터로 인증 정보를 주었을 때 인증이 성공하면 리다이렉트할 URL
- 스프링 부트 2 시큐리티에서는 기본적으로 {도메인}/login/oauth2/code/{소셜 서비스코드} 로 리다이렉트 URL 지원
- 사용자가 별도로 리다이렉트 URL을 지원하는 컨트롤러를 만들 필요가 없다.
scope
- scope의 기본값은 openId, profile, email
- openId라는 scope가 있으면 OpneId Prpvider로 인식하기 때문에, OpenId Provider인 서비스와 그렇지 않은 서비스로 나눠서 각각 OAuth2Service를 만들어야 한다.
- 하나의 OAuth2Service로 사용하기 위해 openId는 빼고 등록
user_name_attribute=response
- 네이버 회원 조회 시 반환되는 JSON의 최상위 필드는 resultCode, message, response
- 스프링 시큐리티에서는 하위 필드를 명시할 수 없기 때문에, 본문을 담고 있는 response를 user_name으로 지정하고, 이후 자바 코드로 response의 id를 user_name으로 지정
User 엔티티
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class User {
@Id @GeneratedValue
@Column(name = "user_id")
private Long id;
@Column(unique = true, nullable = false)
private String email;
@Column(nullable = false)
private String name;
@Column(nullable = false)
@Enumerated(value = EnumType.STRING)
private Role role;
@Builder
public User(String name, String email, Role role) {
this.name = name;
this.email = email;
this.role = role;
}
/*
* 회원 정보 수정
*/
public User update(String name) {
this.name = name;
return this;
}
public String getRoleKey() {
return this.role.getKey();
}
}
Role enum
@Getter
@RequiredArgsConstructor
public enum Role {
// 스프링 시큐리티에서 권한 코드는 항상 ROLE_로 시작
USER("ROLE_USER", "일반 사용자"),
ADMIN("ROLE_ADMIN", "관리자");
private final String key;
private final String title;
}
SecurityConfig
@EnableWebSecurity // 스프링 시큐리티 설정들을 활성화
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private final CustomOAuth2UserService customOAuth2UserService;
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.cors().and()
.csrf().disable()
// url 별 권한 관리
.authorizeRequests()
// 권한 관리 대상을 지정하는 옵션으로, URL, HTTP 메소드별로 관리 가능
.antMatchers("/myPage").hasRole("USER")
.antMatchers("/**").permitAll()
.and()
.logout()
// 로그아웃 성공시 이동할 주소
.logoutSuccessUrl("/")
.and()
.oauth2Login()
// 로그인 성공 이후 사용자 정보를 가져올 때 설정 담당
.userInfoEndpoint()
// 로그인 성공시 UserService 인터페이스의 구현체 등록
// 리소스 서버에서 사용자 정보를 가져온 상태에서 추가로 진행하고자 하는 기능 명시
.userService(customOAuth2UserService);
}
}
CustomOAuth2UserService
로그인 이후 가져온 사용자의 정보들을 기반으로 가입 및 정보수정, 세션 저장등의 기능 지원
@Service
@RequiredArgsConstructor
public class CustomOAuth2UserService
implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
private final UserRepository userRepository;
private final HttpSession httpSession;
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest)
throws OAuth2AuthenticationException {
OAuth2UserService<OAuth2UserRequest, OAuth2User> delegate = new DefaultOAuth2UserService();
OAuth2User oAuth2User = delegate.loadUser(userRequest);
// 현재 로그인 진행 중인 서비스를 구분하는 코드 (구글인지, 네이버인지)
String registrationId = userRequest
.getClientRegistration()
.getRegistrationId();
// OAuth2 로그인 진행 시 키가 되는 필드값
// 구글의 경우 기본적으로 코드를 지원 (sub)
// 네이버, 카카오 등은 기본 지원하지 않는다.
String userNameAttributeName = userRequest
.getClientRegistration()
.getProviderDetails()
.getUserInfoEndpoint()
.getUserNameAttributeName();
// OAuth2UserService를 통해 가져온 OAuth2User의 attribute를 담을 클래스
OAuthAttributes attributes = OAuthAttributes
.of(registrationId,
userNameAttributeName,
oAuth2User.getAttributes());
// user 저장
User user = saveOrUpdate(attributes);
// 세션 설정
httpSession.setAttribute("user", new SessionUser(user));
return new DefaultOAuth2User(Collections.singleton(
new SimpleGrantedAuthority(member.getRoleKey())),
attributes.getAttributes(),
attributes.getNameAttributeKey());
}
private User saveOrUpdate(OAuthAttributes attributes) {
User user = userRepository.findByEmail(attributes.getEmail())
.map(entity -> entity.update(attributes.getName()))
.orElse(attributes.toEntity());
return userRepository.save(user);
}
}
OAuthAttributes
OAuth2UserService를 통해 가져온 OAuth2User의 attribute(이름, 이메일)를 담을 DTO 클래스
@Getter
@Builder
public class OAuthAttributes {
private Map<String, Object> attributes;
private String nameAttributeKey; // OAuth2 로그인 진행 시 키가 되는 필드값
private String name;
private String email;
/*
* 생성 메서드
* registrationId를 통해 서비스에 맞춰 OAuthAttributes 객체 생성 후 반환
*/
public static OAuthAttributes of(String registrationId,
String userNameAttributeName,
Map<String, Object> attributes) {
if ("naver".equals(registrationId)) {
return ofNaver("id", attributes);
}
return ofGoogle(userNameAttributeName, attributes);
}
private static OAuthAttributes ofGoogle(String userNameAttributeName, Map<String, Object> attributes) {
// OAuth2User에서 반환하는 사용자 정보는 map이기 때문에 값 하나하나를 변환
return OAuthAttributes.builder()
.name((String) attributes.get("name"))
.email((String) attributes.get("email"))
.attributes(attributes)
.nameAttributeKey(userNameAttributeName)
.build();
}
private static OAuthAttributes ofNaver(String userNameAttributeName, Map<String, Object> attributes) {
// naver는 사용자 정보가 response를 키로 하는 map 형태로 넘어온다.
Map<String, Object> response = (Map<String, Object>) attributes.get("response");
return OAuthAttributes.builder()
.name((String) response.get("name"))
.email((String) response.get("email"))
.attributes(response)
.nameAttributeKey(userNameAttributeName)
.build();
}
public User toEntity() {
return User.builder()
.name(name)
.email(email)
.role(Role.USER) // 가입 시 기본권한은 USER
.build();
}
}
SessionUser
@Getter
@Scope(value = "session", proxyMode = ScopedProxyMode.TARGET_CLASS)
public class SessionUser implements Serializable {
private Long id;
private String name;
private String email;
public SessionUser() {
}
public SessionUser(User user) {
this.id = user.getId();
this.name = user.getName();
this.email = user.getEmail();
}
}
※ JWT
- JWT는 사용자의 상태를 포함한 의미있는 토큰으로 구성되어 있기 때문에, Auth Server에 검증 요청을 보내야 했던 과정을 서버에서 수행할 수 있게 되어 비용 절감 및 Stateless 아키텍처를 구성할 수 있다.
- 고려할 점은 사용자 인증 정보가 필요한 요청을 보낼 때 헤더에 JWT 토큰값을 넣어 보내야 하기 때문에 데이터가 증가하여 네트워크 부하가 늘어날 수 있다.
- 또한 토큰 자체에 사용자 정보를 담고 있기 때문에 JWT가 만료되기 전에 탈취당하면 서버에서 처리할 수 있는 일이 없고, JWT를 만들어 클라이언트에게 전달하면 제어가 불가능하기 때문에 만료 시간을 필수로 넣어주어야 한다.
'Spring > Spring Security' 카테고리의 다른 글
스프링 시큐리티 설정과 로그인 구현 (0) | 2022.01.25 |
---|---|
JWT 인증 구현 (0) | 2022.01.21 |