OAuth2
Spring Security의 OAuth 2.0 지원은 크게 두 가지 주요 기능으로 구성됩니다:
- OAuth2 Resource Server
- OAuth2 Client
- OAuth2 Log In
추가로 OAuth2 Log In이라는 특별한 기능이 있는데, 이는 독립적으로 존재할 수 없으며 OAuth2 Client 기능을 기반으로 동작합니다. 이러한 기능들은 OAuth 2.0 Authorization Framework에서 정의하는 리소스 서버와 클라이언트 역할을 담당하며, 인증 서버 역할은 Spring Authorization Server라는 별도 프로젝트를 통해 제공됩니다.
OAuth2 문서도 방대하기 때문에 하나씩 차근차근 알아가 보도록 하겠습니다.
시작하기 전에
지금까지 사용하던 InMemoryDB를 더는 사용하지 않고 MySQL을 사용합니다.
application.properties -> application.yaml 을 사용합니다.
@EqualsAndHashCode(of = "id")
@AllArgsConstructor @NoArgsConstructor
@Builder
@Entity
public class User {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String username;
private String password;
@Enumerated(EnumType.STRING)
private Role role;
private String name;
@Enumerated(EnumType.STRING)
private Provider provider;
private String providerId;
}
변경된 User
Provider에 대해선 아래에 나옵니다.
// 신규
@AllArgsConstructor
@Getter
public enum Role {
USER("ROLE_USER"),
ADMIN("ROLE_ADMIN")
;
private final String roleName;
}
// SecurityConfig
http.authorizeHttpRequests(request -> request
.requestMatchers("/user/**").hasAnyRole(Role.USER.name(), Role.ADMIN.name())
.requestMatchers("/admin/**").hasRole(Role.ADMIN.name())
.requestMatchers("/join", "/signup").anonymous()
.anyRequest().permitAll()
);
// UserDetails
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return List.of(new SimpleGrantedAuthority(user.getRole().getRoleName()));
}
변경된 권한 인증방법
Google Cloud Platform
사용자 인증정보에서 승인된 리디렉션 URI를
http://localhost:8080/login/oauth2/code/google
로 설정해줍니다.
의존성 주입
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
OAuth2 Log In은 OAuth2 Client 기반으로 동작하는 기능입니다. 따라서 OAuth2 Client 를 받아줍니다.
Entity 설정
이번에 적용할건 Goole, Kakao 소셜로그인 입니다. 먼저 Provider를 정의해줍니다.
public enum Provider {
GOOGLE,
KAKAO;
}
그리고 User Entity에 넣어줍니다.
application.yaml
spring:
security:
oauth2:
client:
registration:
google:
client-id: GOOGLE_CLIENT_ID
client-secret: GOOGLE_SECRET
scope:
- email
- profile
구글로그인 설정할때 받아둔 Client ID와 Secret Key를 넣어줍니다. scope는
범위에서 설정한 범위를 말하는겁니다.
OAuth2UserService
UserDetailsService와 구현방법이 동일합니다.
@Service
@RequiredArgsConstructor
public class PrincipalOAuth2UserService extends DefaultOAuth2UserService {
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
return super.loadUser(userRequest);
}
}
DefaultOAuth2UserService를 상속받아줍니다.
loadUser에서 소셜로그인을 시도한 유저의 데이터를 확인하고 로그인 처리를 해주면 됩니다.
@Service
@RequiredArgsConstructor
public class PrincipalOAuth2UserService extends DefaultOAuth2UserService {
private final UserDB db;
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2User oAuth2User = super.loadUser(userRequest);
System.out.println(oAuth2User.getAttributes());
Provider provider = Provider.GOOGLE;
String id = oAuth2User.getName();
String username = provider + "_" + id;
User user = db.findByUsername(username)
.orElseGet(() -> db.save(User.builder()
.username(username)
.role(Role.USER)
.name(convert.name())
.provider(provider)
.providerId(id)
.build())
);
return new PrincipalDetails(user);
}
}
먼저 Google 로그인부터 구현해보겠습니다.
- super.loadUser 에서 소셜에서 인증된 사용자를 받아옵니다.
- Provier + "_" + id 를 username으로 사용합니다.
- DB에서 username으로 유저를 찾습니다. 없으면 db에 새로운 유저를 넣어줍니다.
- PrincipalDetails로 반환합니다.
여기에서 id는 소셜의 고유번호입니다. id가 1234134513 인 경우, GOOGLE_1234134513 이 username이 됩니다.
회원가입에 대한 추가 로직이 존재하면 코드를 변경하여 추가 정보를 받도록 변경하면됩니다.
SecurityConfig
http.oauth2Login(login -> login
.loginPage("/join")
.defaultSuccessUrl("/user")
.userInfoEndpoint(endPoint -> endPoint
.userService(principalOAuth2UserService)
)
)
userInfoEndPoint에서 userService에 위에서 만든 PrincipalOAuth2UserService를 넣어줍니다.
HTML
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<form th:action="@{/join}" th:method="post">
<input type="text" name="username">
<input type="password" name="password">
<label>
<input type="checkbox" name="remember-me" /> Remember Me
</label>
<input type="submit" value="로그인">
</form>
<a href="/oauth2/authorization/google">구글로그인</a>
<a href="/oauth2/authorization/kakao">카카오로그인</a>
</body>
</html>
/oauth2/authorization/google
/oauth2/authorization/kakao
를 추가해줍니다. 카카오는 이후에 개발하겠습니다.
결과
이제 서버를 키고 구글로그인을 눌러 로그인을 시도해봅시다.
잘 작동합니다.
뭔가 이상합니다. 저희는 기존코드에는 손도 안대고 security 에 oAuth2Login에 Service만 끼워넣었을뿐인데 기능이 동작합니다.
그리고 '/oauth2/authorization/google'나 redirect URI인 '/login/oauth2/code/google' 부분은 Controller도 건드리지 않았는데 어떻게 동작한걸까요?
코드 파보기
CommonOAuth2Provider
지금 이 글을 읽고 있는 시점에 얼마나 많은 사람이 Google 소셜로그인을 개발했을까요? 수 백만명은 될겁니다. 그렇기에 Spring Security에서 가장 많이 사용하는 소셜은 미리 구현해놓은 겁니다.
public enum CommonOAuth2Provider {
GOOGLE {
public ClientRegistration.Builder getBuilder(String registrationId) {
ClientRegistration.Builder builder = this.getBuilder(registrationId, ClientAuthenticationMethod.CLIENT_SECRET_BASIC, "{baseUrl}/{action}/oauth2/code/{registrationId}");
builder.scope(new String[]{"openid", "profile", "email"});
builder.authorizationUri("https://accounts.google.com/o/oauth2/v2/auth");
builder.tokenUri("https://www.googleapis.com/oauth2/v4/token");
builder.jwkSetUri("https://www.googleapis.com/oauth2/v3/certs");
builder.issuerUri("https://accounts.google.com");
builder.userInfoUri("https://www.googleapis.com/oauth2/v3/userinfo");
builder.userNameAttributeName("sub");
builder.clientName("Google");
return builder;
}
},
GITHUB {
public ClientRegistration.Builder getBuilder(String registrationId) {
ClientRegistration.Builder builder = this.getBuilder(registrationId, ClientAuthenticationMethod.CLIENT_SECRET_BASIC, "{baseUrl}/{action}/oauth2/code/{registrationId}");
builder.scope(new String[]{"read:user"});
builder.authorizationUri("https://github.com/login/oauth/authorize");
builder.tokenUri("https://github.com/login/oauth/access_token");
builder.userInfoUri("https://api.github.com/user");
builder.userNameAttributeName("id");
builder.clientName("GitHub");
return builder;
}
},
FACEBOOK {
public ClientRegistration.Builder getBuilder(String registrationId) {
ClientRegistration.Builder builder = this.getBuilder(registrationId, ClientAuthenticationMethod.CLIENT_SECRET_POST, "{baseUrl}/{action}/oauth2/code/{registrationId}");
builder.scope(new String[]{"public_profile", "email"});
builder.authorizationUri("https://www.facebook.com/v2.8/dialog/oauth");
builder.tokenUri("https://graph.facebook.com/v2.8/oauth/access_token");
builder.userInfoUri("https://graph.facebook.com/me?fields=id,name,email");
builder.userNameAttributeName("id");
builder.clientName("Facebook");
return builder;
}
},
OKTA {
public ClientRegistration.Builder getBuilder(String registrationId) {
ClientRegistration.Builder builder = this.getBuilder(registrationId, ClientAuthenticationMethod.CLIENT_SECRET_BASIC, "{baseUrl}/{action}/oauth2/code/{registrationId}");
builder.scope(new String[]{"openid", "profile", "email"});
builder.userNameAttributeName("sub");
builder.clientName("Okta");
return builder;
}
};
private static final String DEFAULT_REDIRECT_URL = "{baseUrl}/{action}/oauth2/code/{registrationId}";
private CommonOAuth2Provider() {
}
protected final ClientRegistration.Builder getBuilder(String registrationId, ClientAuthenticationMethod method, String redirectUri) {
ClientRegistration.Builder builder = ClientRegistration.withRegistrationId(registrationId);
builder.clientAuthenticationMethod(method);
builder.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE);
builder.redirectUri(redirectUri);
return builder;
}
public abstract ClientRegistration.Builder getBuilder(String registrationId);
}
GOOGLE, GITHUB, FACEBOOK, OKTA 가 보입니다.
redirect url에 "{baseUrl}/{action}/oauth2/code/{registrationId}" 라고 적혀있네요. action에는 login이 들어가면 우리가 구글 클라우드 플랫폼에서 설정한 그 url과 일치합니다.
OAuth2LoginAuthenticationFIlter
public class OAuth2LoginAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
public static final String DEFAULT_FILTER_PROCESSES_URI = "/login/oauth2/code/*";
private static final String AUTHORIZATION_REQUEST_NOT_FOUND_ERROR_CODE = "authorization_request_not_found";
// ... 생략
}
OAuth2AuthorizationRequestRedirectFilter
public class OAuth2AuthorizationRequestRedirectFilter extends OncePerRequestFilter {
public static final String DEFAULT_AUTHORIZATION_REQUEST_BASE_URI = "/oauth2/authorization";
private final ThrowableAnalyzer throwableAnalyzer;
// ... 생략
}
필터에서 해당 URI를 Default로 가지고있습니다. 그래서 우리가 구현하지 않고도 동작한거죠.
그럼 CommonOAuth2Provider에 없는 카카오나 네이버 같은 소셜은 어떻게 해야할까요? 처음부터 전부 다 구현해야할까요?
아닙니다. 쉽게 확장할 수 있습니다. 그건 아래에서 같이 따라해보면서 알아보도록 하겠습니다.
경로 설정
위의 Default 경로를 변경하고 싶다면 아래와 같이 baseUri를 설정하면 됩니다.
http.oauth2Login(oauth2 -> oauth2
.loginPage("/login/oauth2")
...
.authorizationEndpoint(authorization -> authorization
.baseUri("/login/oauth2/authorization")
...
)
);
접속 경로를 변경하고싶은 경우 authorizationEndPoint baseUri를 변경
http.oauth2Login(oauth2 -> oauth2
.redirectionEndpoint(redirection -> redirection
.baseUri("/login/oauth2/callback/*")
...
)
);
Redirect Url을 변경하고싶은 경우 redirectionEndpoint baseUrl를 변경하고,
spring:
security:
oauth2:
client:
registration:
kakao:
client-name: 'kakao'
redirect-uri: '{baseUrl}/login/oauth2/code/{registrationId}'
redirect-uri를 변경하면 됩니다.
카카오 로그인
redirect URI가 위와 같이 잘 설정되어있는지 확인합니다.
application.yaml
spring:
security:
oauth2:
client:
registration:
kakao:
client-name: 'kakao'
client-id: KAKAO_REST_API_KEY
redirect-uri: '{baseUrl}/login/oauth2/code/{registrationId}'
authorization-grant-type: authorization_code
client-authentication-method: client_secret_post
scope:
- profile_nickname
- profile_image
- 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: 'id'
카카오 REST API 키를 client-id에 넣어줍니다. scope는 카카오 로그인 동의항목에서 선택한 ID를 넣어주면됩니다.
저는 프로필, 이름, 이메일을 가져오도록 했습니다.
user-name-attribute는 OAuth2User 객체에 name에 들어가는 파라미터의 Key값입니다.
데이터 구조
먼저 PrincipalOAuth2UserService에 loadUser부터 수정해주어야 합니다. GOOGLE로 하드 코딩된걸 수정할겁니다.
근데 여기서 약간의 문제가 있습니다. Google에 ID 값은 'sub'이고 Kakao에 ID 값은 'id' 인 부분은 user-name-attribute로 통일했기때문에 getName()으로 providerId를 가져오는데 문제가 되지 않지만 이름, 사진, 이메일은 구글과 카카오의 데이터 구조가 완전히 다릅니다. 따라서 converter 해줄 객체가 필요합니다.
@Builder
public record DefaultOAuth2User(String providerId,
String email,
String name,
String picture) {
}
매핑될 객체를 하나 만들어주고,
@Getter
@AllArgsConstructor
public enum Provider {
GOOGLE(user -> DefaultOAuth2User.builder()
.providerId(user.getName())
.email(user.getAttribute("email"))
.name(user.getAttribute("name"))
.picture(user.getAttribute("picture"))
.build()),
KAKAO(user -> {
Map<String, Object> kakaoAccount = user.getAttribute("kakao_account");
Map<String, Object> attribute = (Map<String, Object>) kakaoAccount.get("profile");
return DefaultOAuth2User.builder()
.providerId(user.getName())
.email((String) kakaoAccount.get("email"))
.name((String) attribute.get("nickname"))
.picture((String) attribute.get("profile_image_url"))
.build();
});
private final Function<OAuth2User, DefaultOAuth2User> converter;
public static Provider findByProvider(String clientName) {
return Provider.valueOf(clientName.toUpperCase());
}
public DefaultOAuth2User convert(OAuth2User user) {
return converter.apply(user);
}
}
Provider enum객체를 활용해서 다음과 같이 매핑해주었습니다. convert로 객체를 변환해주는 역할을 하게 되었습니다.
사실 위 코드가 마음에 안들어서
public sealed interface OAuth2Converter permits
OAuth2GoogleConverter,
OAuth2KakaoConverter {
DefaultOAuth2User convert(OAuth2User user);
}
public final class OAuth2GoogleConverter implements OAuth2Converter {
@Override
public DefaultOAuth2User convert(OAuth2User user) {
return DefaultOAuth2User.builder()
.providerId(user.getName())
.email(user.getAttribute("email"))
.name(user.getAttribute("name"))
.picture(user.getAttribute("picture"))
.build();
}
}
public final class OAuth2KakaoConverter implements OAuth2Converter {
@Override
public DefaultOAuth2User convert(OAuth2User user) {
Map<String, Object> kakaoAccount = user.getAttribute("kakao_account");
Map<String, Object> attribute = (Map<String, Object>) kakaoAccount.get("profile");
return DefaultOAuth2User.builder()
.providerId(user.getName())
.email((String) kakaoAccount.get("email"))
.name((String) attribute.get("nickname"))
.picture((String) attribute.get("profile_image_url"))
.build();
}
}
@Getter
@AllArgsConstructor
public enum Provider {
GOOGLE(new OAuth2GoogleConverter()),
KAKAO(new OAuth2KakaoConverter());
private final OAuth2Converter converter;
public static Provider findByProvider(String clientName) {
return Provider.valueOf(clientName.toUpperCase());
}
public DefaultOAuth2User convert(OAuth2User user) {
return converter.convert(user);
}
}
이렇게 하니까 좀더 깔끔해졌습니다.
PrincipalOAuth2UserService
@Service
@RequiredArgsConstructor
public class PrincipalOAuth2UserService extends DefaultOAuth2UserService {
private final UserDB db;
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2User oAuth2User = super.loadUser(userRequest);
Provider provider = Provider.findByProvider(userRequest.getClientRegistration().getClientName());
DefaultOAuth2User convert = provider.convert(oAuth2User);
String username = provider + "_" + convert.providerId();
User user = db.findByUsername(username)
.orElseGet(() -> db.save(User.builder()
.username(username)
.role(Role.USER)
.name(convert.name())
.provider(provider)
.providerId(convert.providerId())
.build())
);
return new PrincipalDetails(user);
}
}
결과
이제 테스트해보면 카카오 로그인도 아주 간단하게 연동했습니다.
application.yaml 을 사용하지 않고 추가하는 방법도 있습니다.
@Configuration
public class OAuth2LoginConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(authorize -> authorize
.anyRequest().authenticated()
)
.oauth2Login(withDefaults());
return http.build();
}
@Bean
public ClientRegistrationRepository clientRegistrationRepository() {
return new InMemoryClientRegistrationRepository(this.googleClientRegistration());
}
private ClientRegistration googleClientRegistration() {
return ClientRegistration.withRegistrationId("google")
.clientId("google-client-id")
.clientSecret("google-client-secret")
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.redirectUri("{baseUrl}/login/oauth2/code/{registrationId}")
.scope("openid", "profile", "email", "address", "phone")
.authorizationUri("https://accounts.google.com/o/oauth2/v2/auth")
.tokenUri("https://www.googleapis.com/oauth2/v4/token")
.userInfoUri("https://www.googleapis.com/oauth2/v3/userinfo")
.userNameAttributeName(IdTokenClaimNames.SUB)
.jwkSetUri("https://www.googleapis.com/oauth2/v3/certs")
.clientName("Google")
.build();
}
}
그런데 key가 노출되기때문에 사용은 안할겁니다. 궁금하시다면 공식문서를 참고해주세요.
https://docs.spring.io/spring-security/reference/servlet/oauth2/login/core.html
결론
소셜로그인을 추가하는건 생각보다 간단합니다. 혼자 공부하면서 작성한거라 틀린부분이 있을 수 있습니다. 언제든지 지적해주세요.
'FrameWork > Spring' 카테고리의 다른 글
[Spring Security] HttpSecurity 메소드를 파보자 (0) | 2025.01.07 |
---|---|
[Spring Security][OAuth2] OpenID Connect(OIDC) 구현 (2) (0) | 2024.12.30 |
[Spring Security][OAuth2] OAuth2 준비하기 - 카카오로그인 설정 (0) | 2024.12.19 |
[Spring Security][OAuth2] OAuth2 준비하기 - 구글로그인 설정 (1) | 2024.12.18 |
[Spring Security] 스프링 시큐리티 SessionManagement 적용하기 (6) (0) | 2024.12.18 |