[Spring/SpringBoot] SpringSecurity없이 OAuth 구글 소셜로그인 직접 구현하기
소셜로그인을 직접 구현하기 위해서는 오어스에 대한 이해가 필요합니다.
https://guide.ncloud-docs.com/docs/b2bpls-oauth2
먼저 오어스의 개념을 학습한 뒤에 구현을 진행해보겠습니다.
구현에서 중요한 포인트는
1. 각 소셜로그인을 로그인을 위한 주소가 필요하다
2. 리소스 오너(로그인을 하는 주체)가 로그인을 하고나서는 Authorization Code라는 것이 발급되고
3. 이 코드로 리소스 서버(로그인을 하는 주체의 정보)에 접근 가능한 Access Token을 발급 받아
4. 그 토큰으로 정보를 가져온다는 것입니다.
아래 코드의 구현에서는
플로우 기준 설명
1. 프론트가 로그인을 하고자 하는 social provider(구글 , 페이스북 등)의 인증을 위한 url을 받습니다.
2. 프론트에서 인증 url을 통해 사용자가 로그인을 하고 Authorization code를 프론트에 주고,
3. 프론트는 이 코드를 서버에 넘겨주면서 -> 엑세스 토큰 발급 -> 발급 받은 걸로 사용자 정보 조회 -> 서버의 인증용(서버 로그인용) 엑세스토큰 발급해서 반환
구현 기준 설명
1. 소셜 로그인 인터페이스를 만들어서 각 로그인마다 인증 url, 소셜로그인 로직 구현할 수 잇도록
2. 소셜 로그인 서비스를 제공해주는 EnumMap을 통해서, 소셜로그인 서비스 들을 미리 불러놓고 바로 사용 가능하게 함
중간에 Operation관련 어노테이션은 swagger 설정 어노테이션입니다.
@RequiredArgsConstructor
@RestController
@RequestMapping(value = "/api/v1/login", produces = "application/json")
public class LoginController {
private final LoginService loginService;
@Operation(
summary = "OAuth URL 생성",
description = "OAuth 인증을 위한 URL을 반환합니다.",
responses = {
@ApiResponse(responseCode = "200", description = "OAuth 인증 URL을 반환합니다.")
}
)
@GetMapping("/oauth/{provider}/authorize")
public ResponseEntity<UriResponse> getAuthorizationUrl(
@PathVariable SocialType provider,
@RequestParam String redirectUri
) {
log.info("getAuthorizationUrl provider={}, redirectUri={}", provider, redirectUri);
String authorizationUrl = loginService.getAuthorizationUrl(provider, redirectUri);
log.info("authorizationUrl={}", authorizationUrl);
return ResponseEntity.ok(new UriResponse(authorizationUrl));
}
@Operation(
summary = "소셜 로그인",
description = "소셜 프로바이더를 통해 로그인하고 JWT 토큰을 반환합니다.",
responses = {
@ApiResponse(responseCode = "201", description = "JWT 토큰을 반환합니다.")
}
)
@PostMapping("/social/{provider}")
public ResponseEntity<LoginResponse> socialLogin(
@PathVariable SocialType provider,
@RequestBody SocialLoginRequest body
) {
LoginResponse response = LoginResponse.from(
loginService.socialLogin(body.getCode(), provider, body.getRedirectUri())
);
System.out.println(response.getToken());
return ResponseEntity.ok(response);
}
@PostMapping("/email")
public ResponseEntity<LoginResponse> emailLogin(@RequestBody EmailLoginRequest request) {
log.info("Email login attempt for email={}", request.getEmail());
LoginResponse response = LoginResponse.from(loginService.emailLogin(
EmailLoginCommand.from(request)
));
return ResponseEntity.ok(response);
}
}
@RequiredArgsConstructor
@Service
public class LoginService {
private final UserService userService;
private final OAuthServiceProvider oAuthServiceProvider;
private final BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
private final JwtUtil jwtUtil;
public LoginDTO socialLogin(String code, SocialType type, String redirectUri) {
OAuthService oAuthService = oAuthServiceProvider.getService(type);
if (oAuthService == null) {
throw new NotFoundException("SOCIAL_NOT_FOUND","지원되지 않는 소셜 로그인 입니다");
}
String accessToken = oAuthService.getAccessToken(code, redirectUri);
UserCommand userCommand = oAuthService.getUserProfile(accessToken);
// 여기까지가 사용자 프로필을 소셜로그인 서버에서 받는 코드
// 그 아래 로직은 엑세스 토큰을 발급하던지, 아니면 서버인증을 하던지 알아서 로그인 로직 구현
UserDTO userDTO = userService.createSocialUser(userCommand);
String token = jwtUtil.createToken(AuthClaims.fromUser(userDTO), Instant.now());
return LoginDTO.from( token, userDTO);
}
public String getAuthorizationUrl(SocialType type, String redirectUri) {
OAuthService oAuthService = oAuthServiceProvider.getService(type);
if (oAuthService == null) {
throw new NotFoundException("SOCIAL_NOT_FOUND","지원되지 않는 소셜 로그인 입니다");
}
return oAuthService.getAuthorizationUrl(redirectUri);
}
public LoginDTO emailLogin(EmailLoginCommand command) {
UserDTO user = userService.getUserByEmail(command.getEmail());
if (!passwordEncoder.matches(command.getPassword(), user.getPassword())) {
throw new TokenException(TOKEN_INVALID);
}
String token = jwtUtil.createToken(AuthClaims.fromUser(user), Instant.now());
return new LoginDTO(token,user.getUserId(), user.getNickname(), user.getAvatarUrl());
}
}
public interface OAuthService {
String getAuthorizationUrl(String redirectUri);
String getAccessToken(String authorizationCode, String redirectUri);
UserCommand getUserProfile(String accessToken);
}
@RequiredArgsConstructor
@Component
public class GoogleOAuthService implements OAuthService {
private final GoogleOAuthProperties googleOAuthProperties;
private final RestTemplate restTemplate = new RestTemplate();
@Override
public String getAuthorizationUrl(String redirectUri) {
return googleOAuthProperties.getEndPoint() +
"?client_id=" + googleOAuthProperties.getClientId() +
"&redirect_uri=" + redirectUri +
"&response_type=code" +
"&scope=" + googleOAuthProperties.getScopes();
}
@Override
public String getAccessToken(String authorizationCode, String redirectUri) {
String clientId = googleOAuthProperties.getClientId();
String clientSecret = googleOAuthProperties.getClientSecret();
String tokenUri = googleOAuthProperties.getTokenUri();
MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
params.add("code", this.sanitizeAuthCode(authorizationCode));
params.add("client_id", clientId);
params.add("client_secret", clientSecret);
params.add("redirect_uri", redirectUri);
params.add("grant_type", "authorization_code");
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
HttpEntity entity = new HttpEntity(params, headers);
ResponseEntity<JsonNode> responseNode = restTemplate.exchange(tokenUri, HttpMethod.POST, entity, JsonNode.class);
JsonNode accessTokenNode = responseNode.getBody();
return accessTokenNode.get("access_token").asText();
}
/**
* Google OAuth 인증 코드가 URL Encode 되어 있으므로 디코딩하여 반환합니다.
*/
private String sanitizeAuthCode(String authCode) {
return URLDecoder.decode(authCode, StandardCharsets.UTF_8);
}
private JsonNode getUserResource(String accessToken) {
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + accessToken);
HttpEntity entity = new HttpEntity(headers);
return restTemplate.exchange(googleOAuthProperties.getUserInfoUri(), HttpMethod.GET, entity, JsonNode.class).getBody();
}
@Override
public UserCommand getUserProfile(String accessToken) {
JsonNode userResource = getUserResource(accessToken);
String id = userResource.get("sub").asText();
String email = userResource.get("email").asText();
String name = userResource.get("name").asText();
return UserCommand.builder()
.name(name)
.email(email)
.socialId(id)
.socialType("GOOGLE").build();
}
}
@Component
public class OAuthServiceProvider {
private final ApplicationContext applicationContext;
private final Map<SocialType, OAuthService> serviceMap = new EnumMap<>(SocialType.class);
public OAuthServiceProvider(ApplicationContext applicationContext) {
this.applicationContext = applicationContext;
}
@PostConstruct
public void init() {
for (SocialType type : SocialType.values()) {
if (type.getClazz() != null) {
serviceMap.put(type, applicationContext.getBean(type.getClazz()));
}
}
}
public OAuthService getService(SocialType type) {
OAuthService service = serviceMap.get(type);
if (service != null) {
return service;
}
throw new BadRequestException("", "지원하지 않는 소셜 로그인입니다. " + type.name());
}
}
@Getter
public enum SocialType {
GOOGLE(GoogleOAuthService.class),
NONE;
private final Class<? extends OAuthService> clazz;
SocialType(Class<? extends OAuthService> clazz) {
this.clazz = clazz;
}
SocialType() {
this.clazz = null;
}
}
참고로 소셜로그인이 아닌 이메일 로그인일때 socialType을 일관성있게 관리해주기 위해 none 이라는 Enum을 추가하였습니다.
이상한 점이나 부족한 부분들은 댓글에 달아주시면 감사하겠습니당~~