Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ dependencies {

// s3
implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE'

// oauth2
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
}

tasks.named('test') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ public enum ErrorStatus implements BaseErrorCode {
NOT_AUTHORIZED(HttpStatus.BAD_REQUEST, "AUTH400", "인증되지 않은 요청입니다."),
ID_OR_PASSWORD_WRONG(HttpStatus.BAD_REQUEST, "AUTH401", "아이디 혹은 비밀번호가 잘못되었습니다."),
INVALID_REFRESH_TOKEN(HttpStatus.BAD_REQUEST, "AUTH402", "유효하지 않은 리프레시 토큰입니다."),
OAUTH_TOKEN_REQUEST_FAILED(HttpStatus.BAD_REQUEST, "AUTH403", "외부인증 토큰 요청에 실패했습니다."),
OAUTH_USERINFO_REQUEST_FAILED(HttpStatus.BAD_REQUEST, "AUTH404", "외부인증 유저 정보 요청에 실패했습니다."),
AUTHORIZATION_METHOD_ERROR(HttpStatus.BAD_REQUEST, "AUTH405", "인증 방식이 잘못되었습니다."),
INVALID_OAUTH_PROVIDER(HttpStatus.BAD_REQUEST, "AUTH406", "유효하지 않은 OAuth 제공자입니다."),

AWS_SERVICE_UNAVAILABLE(HttpStatus.BAD_REQUEST, "AWS400", "AWS S3에 파일을 업로드할 수 없습니다.");

Expand Down
5 changes: 2 additions & 3 deletions src/main/java/umc/codeplay/config/SecurityConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,10 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
auth
// 로그인, 회원가입 등 토큰 없이 접근해야 하는 API 허용
.requestMatchers(
"/oauth/**",
"/health",
"/health/s3",
"/auth/refresh",
"/auth/signup",
"/auth/login",
"/auth/**",
"/v2/api-docs",
"/v3/api-docs",
"/v3/api-docs/**",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package umc.codeplay.config.properties;

import lombok.Data;

@Data
public class BaseOAuthProperties {

private String clientId;
private String clientSecret;
private String redirectUri;
private String scope;
private String authorizationUri;
private String tokenUri;
private String userInfoUri;
private String additionalParameters;

public String getUrl() {
return authorizationUri
+ "?client_id="
+ clientId
+ "&redirect_uri="
+ redirectUri
+ "&response_type=code"
+ "&scope="
+ scope
+ additionalParameters;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package umc.codeplay.config.properties;

import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

@Component
@ConfigurationProperties(prefix = "google.oauth2")
public class GoogleOAuthProperties extends BaseOAuthProperties {
// BaseOAuthProperties 의 필드를 그대로 상속받아 사용.
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package umc.codeplay.config.properties;

import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

@Component
@ConfigurationProperties(prefix = "kakao.oauth2")
public class KakaoOAuthProperties extends BaseOAuthProperties {
// BaseOAuthProperties 의 필드를 그대로 상속받아 사용.
}
12 changes: 5 additions & 7 deletions src/main/java/umc/codeplay/controller/AuthController.java
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,12 @@

import lombok.RequiredArgsConstructor;

import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import umc.codeplay.apiPayLoad.ApiResponse;
import umc.codeplay.apiPayLoad.code.status.ErrorStatus;
import umc.codeplay.apiPayLoad.exception.handler.GeneralHandler;
import umc.codeplay.converter.MemberConverter;
import umc.codeplay.domain.Member;
import umc.codeplay.domain.enums.SocialStatus;
import umc.codeplay.dto.MemberRequestDTO;
import umc.codeplay.dto.MemberResponseDTO;
import umc.codeplay.jwt.JwtUtil;
Expand All @@ -37,6 +37,10 @@ public class AuthController {
@PostMapping("/login")
public ApiResponse<MemberResponseDTO.LoginResultDTO> login(
@RequestBody MemberRequestDTO.LoginDto request) {
if (memberService.getSocialStatus(request.getEmail()) != SocialStatus.NONE) {
throw new GeneralHandler(ErrorStatus.AUTHORIZATION_METHOD_ERROR);
}

// 아이디/비밀번호를 사용해 AuthenticationToken 생성
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(request.getEmail(), request.getPassword());
Expand Down Expand Up @@ -98,10 +102,4 @@ public ApiResponse<MemberResponseDTO.LoginResultDTO> refresh(
throw new GeneralHandler(ErrorStatus.INVALID_REFRESH_TOKEN);
}
}

@SecurityRequirement(name = "JWT TOKEN")
@GetMapping("/test")
public ApiResponse<String> test() {
return ApiResponse.onSuccess("test");
}
}
149 changes: 149 additions & 0 deletions src/main/java/umc/codeplay/controller/OAuthController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
package umc.codeplay.controller;

import java.util.List;
import java.util.Map;

import org.springframework.http.*;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.servlet.view.RedirectView;

import lombok.RequiredArgsConstructor;

import umc.codeplay.apiPayLoad.ApiResponse;
import umc.codeplay.apiPayLoad.code.status.ErrorStatus;
import umc.codeplay.apiPayLoad.exception.handler.GeneralHandler;
import umc.codeplay.config.properties.BaseOAuthProperties;
import umc.codeplay.config.properties.GoogleOAuthProperties;
import umc.codeplay.config.properties.KakaoOAuthProperties;
import umc.codeplay.domain.Member;
import umc.codeplay.domain.enums.SocialStatus;
import umc.codeplay.dto.MemberResponseDTO;
import umc.codeplay.jwt.JwtUtil;
import umc.codeplay.service.MemberService;

@RestController
@RequestMapping("/oauth")
@RequiredArgsConstructor
public class OAuthController {

private final JwtUtil jwtUtil;
private final RestTemplate restTemplate = new RestTemplate();
private final GoogleOAuthProperties googleOAuthProperties;
private final KakaoOAuthProperties kakaoOAuthProperties;
private final MemberService memberService;

@GetMapping("/authorize/{provider}")
public RedirectView redirectToOAuth(@PathVariable("provider") String provider) {
// CSRF 방어용 state, PKCE(code_challenge)..는 굳이
BaseOAuthProperties properties =
switch (provider) {
case "google" -> googleOAuthProperties;
case "kakao" -> kakaoOAuthProperties;
default -> throw new GeneralHandler(ErrorStatus.INVALID_OAUTH_PROVIDER);
};

String url = properties.getUrl();

RedirectView redirectView = new RedirectView();
redirectView.setUrl(url);
return redirectView;
}

@GetMapping("/callback/{provider}")
public ApiResponse<MemberResponseDTO.LoginResultDTO> OAuthCallback(
@RequestParam("code") String code, @PathVariable("provider") String provider) {
BaseOAuthProperties properties =
switch (provider) {
case "google" -> googleOAuthProperties;
case "kakao" -> kakaoOAuthProperties;
default -> throw new GeneralHandler(ErrorStatus.INVALID_OAUTH_PROVIDER);
};
// (1) 받은 code 로 구글 토큰 엔드포인트에 Access/ID Token 교환
Map<String, Object> tokenResponse = requestOAuthToken(code, properties);

// (2) 받아온 Access Token(or ID Token)을 통해 사용자 정보 가져오기
// String idToken = (String) tokenResponse.get("id_token"); // OIDC
String accessToken = (String) tokenResponse.get("access_token");
Map<String, Object> userInfo = requestOAuthUserInfo(accessToken, properties);
String email = null;
String name = null;
switch (provider) {
case "google" -> {
// (3-a) 구글 UserInfo Endpoint 로 이메일, 프로필 등 조회
email = (String) userInfo.get("email");
name = (String) userInfo.get("name");
}
case "kakao" -> {
// (3-b) 카카오 UserInfo Endpoint 로 이메일, 프로필 등 조회
Map<String, Object> kakaoAccount =
(Map<String, Object>) userInfo.get("kakao_account");
Map<String, Object> kakaoProperties =
(Map<String, Object>) userInfo.get("properties");
email = (String) kakaoAccount.get("email");
name = (String) kakaoProperties.get("nickname");
}
}

// (4) 우리 DB에서 회원 조회 or 생성
Member member =
memberService.findOrCreateOAuthMember(
email, name, SocialStatus.valueOf(provider.toUpperCase()));

// (5) JWTUtil 이용해서 Access/Refresh 토큰 발급
var authorities = List.of(new SimpleGrantedAuthority("ROLE_" + member.getRole().name()));

String serviceAccessToken = jwtUtil.generateToken(email, authorities);
String serviceRefreshToken = jwtUtil.generateRefreshToken(email, authorities);

// (6) 최종적으로 JWT(액세스/리프레시)를 프론트에 응답
return ApiResponse.onSuccess(
MemberResponseDTO.LoginResultDTO.builder()
.email(email)
.token(serviceAccessToken)
.refreshToken(serviceRefreshToken)
.build());
}

private Map<String, Object> requestOAuthToken(String code, BaseOAuthProperties properties) {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);

MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
params.add("grant_type", "authorization_code");
params.add("client_id", properties.getClientId());
params.add("client_secret", properties.getClientSecret());
params.add("redirect_uri", properties.getRedirectUri());
params.add("code", code);

HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(params, headers);

ResponseEntity<Map> response =
restTemplate.postForEntity(properties.getTokenUri(), request, Map.class);

if (response.getStatusCode() == HttpStatus.OK) {
return response.getBody();
}
throw new GeneralHandler(ErrorStatus.OAUTH_TOKEN_REQUEST_FAILED);
}

private Map<String, Object> requestOAuthUserInfo(
String accessToken, BaseOAuthProperties properties) {
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + accessToken);

HttpEntity<Void> request = new HttpEntity<>(headers);

ResponseEntity<Map> response =
restTemplate.exchange(
properties.getUserInfoUri(), HttpMethod.GET, request, Map.class);

if (response.getStatusCode() == HttpStatus.OK) {
return response.getBody();
}
throw new GeneralHandler(ErrorStatus.OAUTH_USERINFO_REQUEST_FAILED);
}
}
5 changes: 4 additions & 1 deletion src/main/java/umc/codeplay/converter/MemberConverter.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package umc.codeplay.converter;

import umc.codeplay.domain.Member;
import umc.codeplay.domain.enums.Role;
import umc.codeplay.domain.enums.SocialStatus;
import umc.codeplay.dto.MemberRequestDTO;
import umc.codeplay.dto.MemberResponseDTO;

Expand All @@ -12,7 +14,8 @@ public static Member toMember(MemberRequestDTO.JoinDto request) {
.name(request.getName())
.email(request.getEmail())
.password(request.getPassword())
.role(request.getRole())
.role(Role.USER)
.socialStatus(SocialStatus.NONE)
.build();
}

Expand Down
12 changes: 7 additions & 5 deletions src/main/java/umc/codeplay/domain/Member.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,14 @@

import jakarta.persistence.*;

import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.*;

import umc.codeplay.domain.enums.Role;
import umc.codeplay.domain.enums.SocialStatus;

@Entity
@Getter
@Setter
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
Expand All @@ -27,8 +25,12 @@ public class Member {

private String email;

@Enumerated(EnumType.STRING)
private Role role;

@Enumerated(EnumType.STRING)
private SocialStatus socialStatus;

public void encodePassword(String password) {
this.password = password;
}
Expand Down
7 changes: 7 additions & 0 deletions src/main/java/umc/codeplay/domain/enums/SocialStatus.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package umc.codeplay.domain.enums;

public enum SocialStatus {
GOOGLE,
KAKAO,
NONE
}
3 changes: 0 additions & 3 deletions src/main/java/umc/codeplay/dto/MemberRequestDTO.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,13 @@

import lombok.Getter;

import umc.codeplay.domain.enums.Role;

public class MemberRequestDTO {

@Getter
public static class JoinDto {
String name;
String email;
String password;
Role role;
}

@Getter
Expand Down
31 changes: 31 additions & 0 deletions src/main/java/umc/codeplay/service/MemberService.java
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
import umc.codeplay.apiPayLoad.exception.handler.GeneralHandler;
import umc.codeplay.converter.MemberConverter;
import umc.codeplay.domain.Member;
import umc.codeplay.domain.enums.Role;
import umc.codeplay.domain.enums.SocialStatus;
import umc.codeplay.dto.MemberRequestDTO;
import umc.codeplay.repository.MemberRepository;

Expand All @@ -29,4 +31,33 @@ public Member joinMember(MemberRequestDTO.JoinDto request) {
newMember.encodePassword(passwordEncoder.encode(request.getPassword()));
return memberRepository.save(newMember);
}

public Member findOrCreateOAuthMember(String email, String name, SocialStatus socialStatus) {

Member member = memberRepository.findByEmail(email).orElse(null);

if (member == null) {
member =
Member.builder()
.email(email)
.name(name)
.role(Role.USER)
.socialStatus(socialStatus)
.build();
return memberRepository.save(member);
} else if (member.getSocialStatus() != socialStatus) {
throw new GeneralHandler(ErrorStatus.AUTHORIZATION_METHOD_ERROR);
} else {
return member;
}
}

public SocialStatus getSocialStatus(String email) {
Member member = memberRepository.findByEmail(email).orElse(null);
if (member == null) {
return SocialStatus.NONE;
} else {
return member.getSocialStatus();
}
}
}
Loading
Loading