결과물
GitHub - jsween5723/spring-security-tistory: 티스토리 블로깅을 위한 예제
티스토리 블로깅을 위한 예제. Contribute to jsween5723/spring-security-tistory development by creating an account on GitHub.
github.com
변경된 커밋
UserDetails 클래스에 ID 추가
* refactor · jsween5723/spring-security-tistory@59d32f5
jsween5723 committed Aug 31, 2024
github.com
회원가입 기능 추가
name 프로퍼티는 인증정보와 분리하기 위해 별도의 UserInformationEntity
를 생성했다.
3-1 회원가입 기능 추가 · jsween5723/spring-security-tistory@c07fa95
jsween5723 committed Aug 31, 2024
github.com
간편로그인 기능 추가
build.gradle
dependencies{
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
}
config 추가 코드
private final OAuth2Loader oAuth2Loader;
...
http.oauth2Login(conf -> conf.authorizationEndpoint(end -> end.baseUri("/oauth2/login"))
.userInfoEndpoint(end -> end.userService(oAuth2Loader))
.loginProcessingUrl("/api/v1/oauth2/login/*")
.successHandler(
((request, response, authentication) -> sendResponse(
response, "로그인 성공")))
.failureHandler(this::entryPoint));
yml
spring:
security:
oauth2:
client:
registration:
kakao:
client-id: ${KAKAO_REST_API_KEY}
# 카카오 개발자 REST API key
redirect-uri: ${KAKAO_REDIRECT_URI}
# 백엔드 테스트용이면, login processing url
# 프론트엔드 있으면, 프론트엔드에서 백엔드 login processing url로 요청보내는 페이지
authorization-grant-type: authorization_code
client-authentication-method: client_secret_post
# client secret 사용 시만 할 것. 아니면 none으로
client-secret: ${KAKAO_CLIENT_SECRET}
# client_secret_post시에만 사용함
client-name: kakao
# ClientRegistration 인스턴스에 key로 저장할 이름
provider:
kakao:
authorization-uri: https://kauth.kakao.com/oauth/authorize
# https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api#request-code
token-uri: https://kauth.kakao.com/oauth/token
# https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api#request-token
user-info-uri: https://kapi.kakao.com/v2/user/me
# https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api#req-user-info
user-name-attribute: id
# DefaultOAuth2UserService에서 DefaultOAuth2User 인스턴스 생성시 nameAttributeKey 필드에 대입
# OAuth2User.getName() -> 내부적으로 해당 키로 해쉬맵 조회하여 값가져옴
# provider측 PK 값 취득 가능
나머지는 oAuth2Loader 내부 로직 구현인데, 얼마되진 않지만 필터 설명 후 설명하겠다.
3. Spring Security OAuth 2 Client 간편 로그인 · jsween5723/spring-security-tistory@ca9cabd
jsween5723 committed Aug 31, 2024
github.com
요구사항
간편로그인은 휴대폰으로 할 때도 있고 컴퓨터로 할 때도 있지만, 실제 정보가 바뀌는 경우가 빈번하다.
커스터마이징할 수 있는 닉네임, 프로필사진등을 제외하고 이름 휴대폰과 같은 개인정보는 바뀔 수 있으므로 로그인 마다 갱신하도록한다.
필터체인
spring oauth2 client에서 사용되는 필터는 다음과 같다.OAuth2LoginAuthenticationFilter
- OAuth2 로그인 후 쿼리파라미터로 전달되는 authorization code로 로그인을 진행하는 필터다.OAuth2AuthorizationRequestRedirectFilter
- yml에서 정의한 authorization uri로 redirect 시켜주는 역할을 한다.
간편로그인 링크 이동
OAuth2AuthorizationRequestRedirectFilter
에서 진행된다.
스프링 시큐리티에선 OAuth2 로그인 구현의 일관성을 위해 인터페이스를 제공한다.
yml만 변경해주면 클라이언트 측에서는 별도로 uri를 변경하지 않아도 된다.
해당 옵션은 OAuth2AuthorizationRequestRedirectFilter
에서 관리되며,
public static final String DEFAULT_AUTHORIZATION_REQUEST_BASE_URI = "/oauth2/authorization";
로 default 값이 지정되어있다. 또한
http.oauth2Login(conf -> conf.authorizationEndpoint(end -> end.baseUri("/oauth2/login")));
로 변경할 수 있다.
해당 base uri를 기준으로 뒤에 /kakao 처럼 클라이언트명을 쓰면 자동으로 간편로그인 페이지 uri를 생성해 redirect 시켜주는 역할을 한다.
OAuth2LoginConfigurer
클래스의 configure 메소드를 보면 어떤 메소드를 변경해야하는지 알 수 있다.
@Override
public void configure(B http) throws Exception {
OAuth2AuthorizationRequestRedirectFilter authorizationRequestFilter;
if (this.authorizationEndpointConfig.authorizationRequestResolver != null) {
authorizationRequestFilter = new OAuth2AuthorizationRequestRedirectFilter(
this.authorizationEndpointConfig.authorizationRequestResolver);
}
else {
String authorizationRequestBaseUri = this.authorizationEndpointConfig.authorizationRequestBaseUri;
// 이 부분이다.
if (authorizationRequestBaseUri == null) {
authorizationRequestBaseUri = OAuth2AuthorizationRequestRedirectFilter.DEFAULT_AUTHORIZATION_REQUEST_BASE_URI;
}
authorizationRequestFilter = new OAuth2AuthorizationRequestRedirectFilter(
OAuth2ClientConfigurerUtils.getClientRegistrationRepository(this.getBuilder()),
authorizationRequestBaseUri);
}
...
}
간편 로그인 기능
동작 제어
OAuth2LoginAuthenticationFilter
에서 수행된다.
기본 API 경로는 해당 필터를 보면
public static final String DEFAULT_FILTER_PROCESSES_URI = "/login/oauth2/code/*";
로 정규표현식으로 표시돼있는 것을 볼 수 있다.
마찬가지로 OAuth2LoginConfigurer
클래스를 보면
private String loginProcessingUrl = OAuth2LoginAuthenticationFilter.DEFAULT_FILTER_PROCESSES_URI;
로그인 진행 경로를 변경할 수 있는 방법을 알 수 있다.
http.oauth2Login(conf -> conf.loginProcessingUrl("/api/v1/oauth2/login/*"));
로그인 후처리
FormLoginConfigurer
와 이 클래스는 같은 부모 클래스를 두고있다.
따라서 로그인 성공 후 redirect도 일반 로그인과 동일하게 지정해주면된다.
2024.08.30 - [Spring/Spring Security] - 2. Spring Security 일반 로그인 [ver 6.3.3]
일반 로그인 필터 O 로그인 폼 페이지 X 하고 싶을 때 항목 참고
2. Spring Security 일반 로그인 [ver 6.3.3]
질문은 자유롭게 남겨주세요!추가된 코드와 커밋private final ObjectMapper objectMapper; @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http.cors(AbstractHttpConfigurer::disable).csrf(AbstractHtt
sween.tistory.com
일반 로그인 필터 O 로그인 폼 페이지 X 하고 싶을 때
REST API 기준으로 success, failure handler를 추가로 지정하겠습니다.
* success, failure handler 추가 · jsween5723/spring-security-tistory@643d0de
jsween5723 committed Aug 31, 2024
github.com
http.oauth2Login(conf -> conf.authorizationEndpoint(end -> end.baseUri("/oauth2/login"))
.loginProcessingUrl("/api/v1/oauth2/login/*")
.successHandler(
((request, response, authentication) -> sendResponse(
response, "로그인 성공")))
.failureHandler(this::entryPoint));
간편 로그인 동작 분석
OAuth2LoginAuthenticationFilter
의 코드
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException {
MultiValueMap<String, String> params = OAuth2AuthorizationResponseUtils.toMultiMap(request.getParameterMap());
if (!OAuth2AuthorizationResponseUtils.isAuthorizationResponse(params)) {
OAuth2Error oauth2Error = new OAuth2Error(OAuth2ErrorCodes.INVALID_REQUEST);
throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
}
//1. 정규표현식 만족하더라도 쿼리스트링 등 불필요 인자로 OAuth2 조건 미충족 시 에러 반환
OAuth2AuthorizationRequest authorizationRequest = this.authorizationRequestRepository
.removeAuthorizationRequest(request, response);
if (authorizationRequest == null) {
OAuth2Error oauth2Error = new OAuth2Error(AUTHORIZATION_REQUEST_NOT_FOUND_ERROR_CODE);
throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
}
String registrationId = authorizationRequest.getAttribute(OAuth2ParameterNames.REGISTRATION_ID);
ClientRegistration clientRegistration = this.clientRegistrationRepository.findByRegistrationId(registrationId);
// 2. yml 기반으로 스프링부트에서 자동 생성해둔 OAuth 공급자 정보 인스턴스 인메모리에서 가져옴
if (clientRegistration == null) {
OAuth2Error oauth2Error = new OAuth2Error(CLIENT_REGISTRATION_NOT_FOUND_ERROR_CODE,
"Client Registration not found with Id: " + registrationId, null);
throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
}
// @formatter:off
String redirectUri = UriComponentsBuilder.fromHttpUrl(UrlUtils.buildFullRequestUrl(request))
.replaceQuery(null)
.build()
.toUriString();
// @formatter:on
OAuth2AuthorizationResponse authorizationResponse = OAuth2AuthorizationResponseUtils.convert(params,
redirectUri);
Object authenticationDetails = this.authenticationDetailsSource.buildDetails(request);
OAuth2LoginAuthenticationToken authenticationRequest = new OAuth2LoginAuthenticationToken(clientRegistration,
new OAuth2AuthorizationExchange(authorizationRequest, authorizationResponse));
authenticationRequest.setDetails(authenticationDetails);
OAuth2LoginAuthenticationToken authenticationResult = (OAuth2LoginAuthenticationToken) this
.getAuthenticationManager()
.authenticate(authenticationRequest);
// 3. OAuth2LoginAuthenticationProvider - OAuth2AuthorizationCodeAuthenticationProvider에서 ClientRegistration의 tokenUri기반 토큰 취득 및 OAuth2UserRequest 생성
// 4. OAuth2LoginAuthenticationProvider - OAuth2OAuth2UserService 통해서 OAuth2User 인스턴스를 Principal로 갖는 Authentication 생성
OAuth2AuthenticationToken oauth2Authentication = this.authenticationResultConverter
.convert(authenticationResult);
// 5. new OAuth2AuthenticationToken(authenticationResult.getPrincipal(), authenticationResult.getAuthorities(),
authenticationResult.getClientRegistration().getRegistrationId()
Assert.notNull(oauth2Authentication, "authentication result cannot be null");
oauth2Authentication.setDetails(authenticationDetails);
OAuth2AuthorizedClient authorizedClient = new OAuth2AuthorizedClient(
authenticationResult.getClientRegistration(), oauth2Authentication.getName(),
authenticationResult.getAccessToken(), authenticationResult.getRefreshToken());
this.authorizedClientRepository.saveAuthorizedClient(authorizedClient, oauth2Authentication, request, response);
return oauth2Authentication;
}
쿼리스트링으로 전달받은 authorization code를 활용해 3번에서 token-endpoint로 access token을 취득하고
4번에서 access token으로 user-info-endpoint를 통해 해쉬맵으로 사용자 정보를 받아 OAuth2User로 변환한다.
OAuth2LoginAuthenticationProvider 동작 분석
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
OAuth2LoginAuthenticationToken loginAuthenticationToken = (OAuth2LoginAuthenticationToken) authentication;
// Section 3.1.2.1 Authentication Request -
// https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest scope
// REQUIRED. OpenID Connect requests MUST contain the "openid" scope value.
if (loginAuthenticationToken.getAuthorizationExchange()
.getAuthorizationRequest()
.getScopes()
.contains("openid")) {
// This is an OpenID Connect Authentication Request so return null
// and let OidcAuthorizationCodeAuthenticationProvider handle it instead
return null;
}
// OpenID 방식이라면 이 Provider다음 Provider를 사용하기위해 null을 반환한다.
// 해당 방식은 엑세스 토큰을 별도로 받지 않고 JWT로 사용자 정보를 id token에 포함하기 때문이다.
// firebase auth 방식을 생각해보면 편하다.
// 다음에 위치한 OidcAuthorizationCodeAuthenticationProvider에게 위임한다.
OAuth2AuthorizationCodeAuthenticationToken authorizationCodeAuthenticationToken;
try {
authorizationCodeAuthenticationToken = (OAuth2AuthorizationCodeAuthenticationToken) this.authorizationCodeAuthenticationProvider
.authenticate(
new OAuth2AuthorizationCodeAuthenticationToken(loginAuthenticationToken.getClientRegistration(),
loginAuthenticationToken.getAuthorizationExchange()));
//authorization code로 토큰엔드포인트에 요청보내 취득하여 포함된 Authentication 반환하는 AuthorizationCodeAuthenticationProvider 활용
}
catch (OAuth2AuthorizationException ex) {
OAuth2Error oauth2Error = ex.getError();
throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString(), ex);
}
OAuth2AccessToken accessToken = authorizationCodeAuthenticationToken.getAccessToken();
Map<String, Object> additionalParameters = authorizationCodeAuthenticationToken.getAdditionalParameters();
OAuth2User oauth2User = this.userService.loadUser(new OAuth2UserRequest(
loginAuthenticationToken.getClientRegistration(), accessToken, additionalParameters));
//OAuth2UserService로 OAuth2User 인스턴스 반환
Collection<? extends GrantedAuthority> mappedAuthorities = this.authoritiesMapper
.mapAuthorities(oauth2User.getAuthorities());
OAuth2LoginAuthenticationToken authenticationResult = new OAuth2LoginAuthenticationToken(
loginAuthenticationToken.getClientRegistration(), loginAuthenticationToken.getAuthorizationExchange(),
oauth2User, mappedAuthorities, accessToken, authorizationCodeAuthenticationToken.getRefreshToken());
// ClientRegistration등 이후 인가, 비즈니스 로직 등에 필요없는 부분이 없는 OAuth2LoginAuthenticationToken로 전환
authenticationResult.setDetails(loginAuthenticationToken.getDetails());
return authenticationResult;
}
AuthorizationCodeAuthenticationProvider 동작 분석
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
OAuth2AuthorizationCodeAuthenticationToken authorizationCodeAuthentication = (OAuth2AuthorizationCodeAuthenticationToken) authentication;
OAuth2AuthorizationResponse authorizationResponse = authorizationCodeAuthentication.getAuthorizationExchange()
.getAuthorizationResponse();
//Authentication에 포함된 쿼리스트링 분석하여 간편로그인 결과 분석
//로그인 결과에 state code error등이 쿼리스트링에 포함돼있음.
if (authorizationResponse.statusError()) {
throw new OAuth2AuthorizationException(authorizationResponse.getError());
}
OAuth2AuthorizationRequest authorizationRequest = authorizationCodeAuthentication.getAuthorizationExchange()
.getAuthorizationRequest();
// access token을 요청하기위한 파라미터 취득 (yml기반으로 생성)
if (!authorizationResponse.getState().equals(authorizationRequest.getState())) {
OAuth2Error oauth2Error = new OAuth2Error(INVALID_STATE_PARAMETER_ERROR_CODE);
throw new OAuth2AuthorizationException(oauth2Error);
}
OAuth2AccessTokenResponse accessTokenResponse = this.accessTokenResponseClient.getTokenResponse(
new OAuth2AuthorizationCodeGrantRequest(authorizationCodeAuthentication.getClientRegistration(),
authorizationCodeAuthentication.getAuthorizationExchange()));
// REST API로 token end point에 요청 보내 결과 취득
OAuth2AuthorizationCodeAuthenticationToken authenticationResult = new OAuth2AuthorizationCodeAuthenticationToken(
authorizationCodeAuthentication.getClientRegistration(),
authorizationCodeAuthentication.getAuthorizationExchange(), accessTokenResponse.getAccessToken(),
accessTokenResponse.getRefreshToken(), accessTokenResponse.getAdditionalParameters());
// 토큰 포함된 Authentication 생성
authenticationResult.setDetails(authorizationCodeAuthentication.getDetails());
return authenticationResult;
//반환
}
이외는 일반 로그인과 같다.
간편 로그인 정보 저장 및 Principal 커스텀하기
OAuth2User 인터페이스는 모든 속성을 해쉬맵으로 관리한다. 제공자마다 속성이 다르기 때문이다.
이에 따라 추후 관리에 용이하기 위해서 별도의 클래스로 만들겠다.
@Getter
public final class GrantedOAuth2User implements OAuth2User, Me {
private final OAuth2Provider providerName;
private final String providerId;
private final Long userId;
private String username;
private String name;
private final Map<String, Object> attributes;
private final Set<UserRole> authorities;
GrantedOAuth2User(KakaoUserInfo kakaoUserInfo, Map<String, Object> attributes) {
this.providerName = OAuth2Provider.kakao;
this.providerId = kakaoUserInfo.id().toString();
this.userId = null;
if (kakaoUserInfo.kakaoAccount() != null) {
KakaoAccount account = kakaoUserInfo.kakaoAccount();
this.username = account.email();
this.name = account.name();
}
this.attributes = attributes;
this.authorities = Set.of(UserRole.USER);
}
public JoinUser toJoinUser() {
return new JoinUser(username, UUID.randomUUID().toString());
}
public UserInformation toInformation() {
return new UserInformation(name);
}
}
카카오에서 제공되는 정보들도 클래스로 관리하겠다.
https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api#req-user-info-response
Kakao Developers
카카오 API를 활용하여 다양한 어플리케이션을 개발해보세요. 카카오 로그인, 메시지 보내기, 친구 API, 인공지능 API 등을 제공합니다.
developers.kakao.com
public record KakaoUserInfo(Long id, @JsonProperty("kakao_account") KakaoAccount kakaoAccount) {
public record KakaoAccount(Profile profile, String name, String email, @JsonProperty("phone_number") String phoneNumber, String ci) {
public record Profile(String nickname, @JsonProperty("thumbnail_image_url") String thumbnail_image_url,
@JsonProperty("profile_image_url") String profileImageUrl) {
}
}
}
OAuth2Service를 재정의하겠다.
@RequiredArgsConstructor
@Component
class OAuth2Loader implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
private final ObjectMapper objectMapper;
private final DefaultOAuth2UserService service = new DefaultOAuth2UserService();
private final UserManager userManager;
private final OAuth2UserRepository repository;
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
Map<String, Object> attributes = service.loadUser(userRequest)
.getAttributes();
// 사용자정보 요청은 DefaultOAuth2UserService에서 제공해주기 때문에 재활용한다.
GrantedOAuth2User oAuthMember = toOAuth2User(userRequest.getClientRegistration()
.getClientName(), attributes);
Long userId = userManager.getIdByUsername(oAuthMember.getUsername());
if (userId == null) {
userId = userManager.join(oAuthMember.toJoinUser());
}
//Optional 사용도 좋은 방법
oAuthMember.assignUserId(userId);
if (!repository.existsByProviderIdAndProviderName(oAuthMember.getProviderId(),
oAuthMember.getProviderName())) {
repository.save(new OAuth2UserEntity(oAuthMember, userId));
}
userManager.renewInformation(userId, oAuthMember.toInformation());
oAuthMember.assignUserId(userId);
return oAuthMember;
}
private GrantedOAuth2User toOAuth2User(String clientName, Map<String, Object> attributes) {
switch (clientName) {
case "kakao" -> {
KakaoUserInfo userInfo = objectMapper.convertValue(attributes, KakaoUserInfo.class);
return new GrantedOAuth2User(userInfo, attributes);
}
default -> throw new RuntimeException("지원하지 않는 간편로그인입니다.");
}
}
}
재정의한 OAuth2UserService를 등록한다.
http.oauth2Login(conf -> conf.authorizationEndpoint(end -> end.baseUri("/oauth2/login"))
.userInfoEndpoint(end -> end.userService(oAuth2Loader))
//이 부분
.loginProcessingUrl("/api/v1/oauth2/login/*")
.successHandler(
((request, response, authentication) -> sendResponse(
response, "로그인 성공")))
.failureHandler(this::entryPoint));
참고:
https://docs.spring.io/spring-security/reference/servlet/oauth2/login/core.html
Core Configuration :: Spring Security
If you are not able to use Spring Boot and would like to configure one of the pre-defined providers in CommonOAuth2Provider (for example, Google), apply the following configuration: OAuth2 Login Configuration @Configuration @EnableWebSecurity public class
docs.spring.io
https://docs.spring.io/spring-security/reference/servlet/oauth2/login/advanced.html
Advanced Configuration :: Spring Security
By default, the OAuth 2.0 Login Page is auto-generated by the DefaultLoginPageGeneratingFilter. The default login page shows each configured OAuth Client with its ClientRegistration.clientName as a link, which is capable of initiating the Authorization Req
docs.spring.io
'Spring > Spring Security' 카테고리의 다른 글
부록: JPA 엔티티에 UserDetails, OAuth2User 구현하지 말기 (0) | 2024.09.01 |
---|---|
부록: 빈 생성 기능과 필터체인 분리 (0) | 2024.09.01 |
Spring Security 6 - 일반 로그인 | 폼 로그인 (1) | 2024.08.30 |
Spring Security 6 - 컴포넌트 빈 정의 (0) | 2024.08.29 |
Spring Security 6 시리즈 작성계획 (0) | 2024.08.29 |