스프링/웹 개발

[Spring/SpringBoot] SpringSecurity없이 OAuth 구글 소셜로그인 직접 구현하기

채야미 2024. 12. 23. 12:59

소셜로그인을 직접 구현하기 위해서는 오어스에 대한 이해가 필요합니다.

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을 추가하였습니다.

 

 

이상한 점이나 부족한 부분들은 댓글에 달아주시면 감사하겠습니당~~