Skip to content

Commit

Permalink
✨ OAuth OIDC 회원가입 및 로그인 API (#33)
Browse files Browse the repository at this point in the history
* feat: provider enum 클래스 정의

* feat: provider converter 정의

* feat: oauth domain 정의

* feat: provider exception 정의

* feat: provider request converter 정의

* feat: web config에 provider converter 등록

* chore: infra 모듈 httpclient 라이브러리 의존성 추가

* feat: oauth 로그인&회원가입 dto 정의

* rename: getter 메서드명 중 oidc 소문자화

* fix: oauth 로그인 시 oauth id 필드 추가

* fix: oauth oidc helper 메서드 추가

* rename: provider exception -> oauth exception

* feat: oauth id 불일치 예외 추가

* feat: oauth repository 정의

* feat: oauth repository 소셜 아이디 & 제공자 탐색 메서드 선언

* feat: oauth domain service 정의

* rename: provider converter 예외 이름 수정

* rename: oauth service get -> read

* style: oauth exception api 모듈 -> domain 모듈 이전

* rename: oauth error code 주석 포맷 변경

* feat: oauth 매퍼클래스 - 로그인 분기처리

* feat: oauth 로그인 use case 구현

* feat: oauth 로그인 컨트롤러 정의

* fix: cache config 내 불필요한 설정 추가 제거

* chore: infra 모듈 redis 환경 변수 주입

* rename: oauth controller 스웨거 문서 설명 추가

* feat: oauth API 설계

* fix: 전화번호 인증 oauth provider 구분

* fix: 전화 번호 인증 열거 타입 oauth provider 추론 메서드 static으로 변경

* fix: 전화번호 인증 코드 응답 객체 general, oauth 정적 팩토리 분리

* fix: oauth 분기 시나리오에 따른 dto 구분

* fix: oauth api 설계 수정

* fix: aouth use case의 verify code 메서드 반환 타입 verify code res로 수정

* rename: 인증 코드 검증 dto 생성 메서드 변경

* feat: oauth provider signup된 정보 존재 시 반환하는 에러코드 추가

* feat: 사용자 아이디 & provider 기반 데이터 존재 여부 검증 도메인 서비스 메서드 추가

* feat: oauth 회원가입 요청을 phone 검증 dto로 변환하는 정적 팩토리 메서드 추가

* fix: 소셜 회원가입 시 phone, code 필수 입력 필드 추가

* feat: user sync mapper 클래스 내 oauth 회원가입 분기 결정 메서드 추가

* feat: oauth use case 메서드 작성

* feat: 소셜 회원가입 분기 등록 로직 구현

* feat: 소셜 회원가입 Use case 작성

* rename: oauth controller 주석 제거

* rename: oauth api 1, 3번 상세한 설명을 위한 swagger 어노테이션 추가

* style: 프로그래밍 코드와 문서 주석 분리

* docs: 인증 코드 검증 예외 문서 수정

* fix: 휴대폰 만료 혹은 미등록 예외 reason code 401->404 변경

* docs: 전화번호 인증 응답 포맷 수정
  • Loading branch information
psychology50 authored Apr 4, 2024
1 parent 9d910ab commit 9b7bde2
Show file tree
Hide file tree
Showing 30 changed files with 797 additions and 29 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
package kr.co.pennyway.api.apis.auth.api;

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.enums.ParameterIn;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.ExampleObject;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import kr.co.pennyway.api.apis.auth.dto.PhoneVerificationDto;
import kr.co.pennyway.api.apis.auth.dto.SignInReq;
import kr.co.pennyway.api.apis.auth.dto.SignUpReq;
import kr.co.pennyway.domain.domains.oauth.type.Provider;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;

@Tag(name = "[소셜 인증 API]")
public interface OauthApi {
@Operation(summary = "[1] 소셜 로그인", description = "기존에 Provider로 가입한 사용자는 로그인, 가입하지 않은 사용자는 전화번호 인증으로 이동")
@Parameter(name = "provider", description = "소셜 제공자", examples = {
@ExampleObject(name = "카카오", value = "kakao"), @ExampleObject(name = "애플", value = "apple"), @ExampleObject(name = "구글", value = "google")
}, required = true, in = ParameterIn.QUERY)
@ApiResponses({
@ApiResponse(responseCode = "200", content = @Content(mediaType = "application/json", examples = {
@ExampleObject(name = "성공 - 기존 계정 있음", value = """
{
"code": "2000",
"data": {
"user": {
"id": 1
}
}
}
"""),
@ExampleObject(name = "성공 - 기존 계정 없음 (id -1인 경우) - [2]로 진행", value = """
{
"code": "2000",
"data": {
"user": {
"id": -1
}
}
}
""")
})),
@ApiResponse(responseCode = "401", content = @Content(mediaType = "application/json", examples = {
@ExampleObject(name = "실패 - 유효하지 않은 idToken", value = """
{
"code": "4013",
"message": "비정상적인 토큰입니다"
}
""")
}))
})
ResponseEntity<?> signIn(@RequestParam Provider provider, @RequestBody @Validated SignInReq.Oauth request);

@Operation(summary = "[2] 인증번호 발송", description = "전화번호 입력 후 인증번호 발송")
@Parameter(name = "provider", description = "소셜 제공자", examples = {
@ExampleObject(name = "카카오", value = "kakao"), @ExampleObject(name = "애플", value = "apple"), @ExampleObject(name = "구글", value = "google")
}, required = true, in = ParameterIn.QUERY)
@ApiResponse(responseCode = "200", content = @Content(mediaType = "application/json", examples = {
@ExampleObject(name = "발신 성공", value = """
{
"code": "2000",
"data": {
"sms": {
"to": "010-1234-5678",
"sendAt": "2024-04-04 00:31:57",
"expiresAt": "2024-04-04 00:36:57"
}
}
}
""")
}))
ResponseEntity<?> sendCode(@RequestParam Provider provider, @RequestBody @Validated PhoneVerificationDto.PushCodeReq request);

@Operation(summary = "[3] 전화번호 인증", description = "전화번호 인증 후 이미 계정이 존재하면 연동, 없으면 회원가입")
@Parameter(name = "provider", description = "소셜 제공자", examples = {
@ExampleObject(name = "카카오", value = "kakao"), @ExampleObject(name = "애플", value = "apple"), @ExampleObject(name = "구글", value = "google")
}, required = true, in = ParameterIn.QUERY)
@ApiResponses({
@ApiResponse(responseCode = "200", content = @Content(mediaType = "application/json", examples = {
@ExampleObject(name = "성공 - 기존 계정 있음 - [4-1]로 진행", value = """
{
"code": "2000",
"data": {
"sms": {
"code": true,
"existUser": true,
"username": "pennyway"
}
}
}
"""),
@ExampleObject(name = "성공 - 기존 계정 없음 - [4-2]로 진행", value = """
{
"code": "2000",
"data": {
"sms": {
"code": true,
"existUser": false
}
}
}
""")
})),
@ApiResponse(responseCode = "401", content = @Content(mediaType = "application/json", examples = {
@ExampleObject(name = "인증코드 불일치", value = """
{
"code": "4010",
"message": "인증코드가 일치하지 않습니다."
}
""")
})),
@ApiResponse(responseCode = "404", content = @Content(mediaType = "application/json", examples = {
@ExampleObject(name = "만료 혹은 등록되지 않은 휴대폰", value = """
{
"code": "4042",
"message": "만료되었거나 등록되지 않은 휴대폰 정보입니다."
}
"""),
}))
})
ResponseEntity<?> verifyCode(@RequestParam Provider provider, @RequestBody @Validated PhoneVerificationDto.VerifyCodeReq request);

@Operation(summary = "[4-1] 계정 연동", description = "일반 혹은 소셜 계정이 존재하는 경우 연동")
@Parameter(name = "provider", description = "소셜 제공자", examples = {
@ExampleObject(name = "카카오", value = "kakao"), @ExampleObject(name = "애플", value = "apple"), @ExampleObject(name = "구글", value = "google")
}, required = true, in = ParameterIn.QUERY)
ResponseEntity<?> linkAuth(@RequestParam Provider provider, @RequestBody @Validated SignUpReq.SyncWithAuth request);

@Operation(summary = "[4-2] 소셜 회원가입", description = "회원 정보 입력 후 회원가입")
@Parameter(name = "provider", description = "소셜 제공자", examples = {
@ExampleObject(name = "카카오", value = "kakao"), @ExampleObject(name = "애플", value = "apple"), @ExampleObject(name = "구글", value = "google")
}, required = true, in = ParameterIn.QUERY)
ResponseEntity<?> signUp(@RequestParam Provider provider, @RequestBody @Validated SignUpReq.Oauth request);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package kr.co.pennyway.api.apis.auth.controller;

import kr.co.pennyway.api.apis.auth.api.OauthApi;
import kr.co.pennyway.api.apis.auth.dto.PhoneVerificationDto;
import kr.co.pennyway.api.apis.auth.dto.SignInReq;
import kr.co.pennyway.api.apis.auth.dto.SignUpReq;
import kr.co.pennyway.api.apis.auth.usecase.OauthUseCase;
import kr.co.pennyway.api.common.response.SuccessResponse;
import kr.co.pennyway.api.common.security.jwt.Jwts;
import kr.co.pennyway.api.common.util.CookieUtil;
import kr.co.pennyway.domain.domains.oauth.type.Provider;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.tuple.Pair;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseCookie;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;

import java.time.Duration;
import java.util.Map;

@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/v1/auth/oauth")
public class OauthController implements OauthApi {
private final OauthUseCase oauthUseCase;
private final CookieUtil cookieUtil;

@Override
@PostMapping("/sign-in")
@PreAuthorize("isAnonymous()")
public ResponseEntity<?> signIn(@RequestParam Provider provider, @RequestBody @Validated SignInReq.Oauth request) {
Pair<Long, Jwts> userInfo = oauthUseCase.signIn(provider, request);

if (userInfo.getLeft().equals(-1L)) {
return ResponseEntity.ok(SuccessResponse.from("user", Map.of("id", -1)));
}
return createAuthenticatedResponse(userInfo);
}

@Override
@PostMapping("/phone")
@PreAuthorize("isAnonymous()")
public ResponseEntity<?> sendCode(@RequestParam Provider provider, @RequestBody @Validated PhoneVerificationDto.PushCodeReq request) {
return ResponseEntity.ok(SuccessResponse.from("sms", oauthUseCase.sendCode(provider, request)));
}

@Override
@PostMapping("/phone/verification")
@PreAuthorize("isAnonymous()")
public ResponseEntity<?> verifyCode(@RequestParam Provider provider, @RequestBody @Validated PhoneVerificationDto.VerifyCodeReq request) {
return ResponseEntity.ok(SuccessResponse.from("sms", oauthUseCase.verifyCode(provider, request)));
}

@Override
@PostMapping("/link-auth")
@PreAuthorize("isAnonymous()")
public ResponseEntity<?> linkAuth(@RequestParam Provider provider, @RequestBody @Validated SignUpReq.SyncWithAuth request) {
return createAuthenticatedResponse(oauthUseCase.signUp(provider, request.toOauthInfo()));
}

@Override
@PostMapping("/sign-up")
@PreAuthorize("isAnonymous()")
public ResponseEntity<?> signUp(@RequestParam Provider provider, @RequestBody @Validated SignUpReq.Oauth request) {
return createAuthenticatedResponse(oauthUseCase.signUp(provider, request.toOauthInfo()));
}

private ResponseEntity<?> createAuthenticatedResponse(Pair<Long, Jwts> userInfo) {
ResponseCookie cookie = cookieUtil.createCookie("refreshToken", userInfo.getValue().refreshToken(), Duration.ofDays(7).toSeconds());
return ResponseEntity.ok()
.header(HttpHeaders.SET_COOKIE, cookie.toString())
.header(HttpHeaders.AUTHORIZATION, userInfo.getValue().accessToken())
.body(SuccessResponse.from("user", Map.of("id", userInfo.getKey())))
;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
public class PhoneVerificationDto {
@Schema(title = "인증번호 요청 DTO", description = "전화번호로 인증번호 송신 요청을 위한 DTO")
public record PushCodeReq(
@Schema(description = "전화번호", example = "01012345678")
@Schema(description = "전화번호", example = "010-1234-5678")
@NotBlank(message = "전화번호는 필수입니다.")
@Pattern(regexp = "^01[01]-\\d{4}-\\d{4}$", message = "전화번호 형식이 올바르지 않습니다.")
String phone
Expand Down Expand Up @@ -59,20 +59,42 @@ public record VerifyCodeReq(
public static VerifyCodeReq from(SignUpReq.Info request) {
return new VerifyCodeReq(request.phone(), request.code());
}

public static VerifyCodeReq from(SignUpReq.OauthInfo request) {
return new VerifyCodeReq(request.phone(), request.code());
}
}

@Schema(title = "인증번호 검증 응답 DTO")
public record VerifyCodeRes(
@Schema(description = "코드 일치 여부 : 일치하지 않으면 예외이므로 성공하면 언제나 true", example = "true")
Boolean code,
@Schema(description = "oauth 사용자 여부", example = "true")
@Schema(description = "oauth 사용자 여부. true면 sync, false면 회원가입으로 진행 (일반 회원가입 시 필수값)", example = "true")
@JsonInclude(JsonInclude.Include.NON_NULL)
Boolean oauth,
@Schema(description = "기존 계정 존재 여부. true면 sync, false면 회원가입 (oauth 회원가입 시 필수값)", example = "true")
@JsonInclude(JsonInclude.Include.NON_NULL)
Boolean existsUser,
@Schema(description = "기존 사용자 아이디", example = "pennyway")
@JsonInclude(JsonInclude.Include.NON_NULL)
String username
) {
public static VerifyCodeRes valueOf(Boolean isValidCode, Boolean isOauthUser, String username) {
return new VerifyCodeRes(isValidCode, isOauthUser, username);
/**
* 일반 회원가입 시 인증 코드 응답 객체 생성
*
* @param isOauthUser Boolean : oauth 사용자 여부. true면 sync, false면 회원가입으로 진행
*/
public static VerifyCodeRes valueOfGeneral(Boolean isValidCode, Boolean isOauthUser, String username) {
return new VerifyCodeRes(isValidCode, isOauthUser, null, username);
}

/**
* oauth 회원가입 시 인증 코드 응답 객체 생성
*
* @param existsUser Boolean : 기존 계정 존재 여부. true면 sync, false면 회원가입으로 진행
*/
public static VerifyCodeRes valueOfOauth(Boolean isValidCode, Boolean existsUser, String username) {
return new VerifyCodeRes(isValidCode, null, existsUser, username);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,15 @@ public record General(
String password
) {
}

@Schema(name = "signInReqOauth", title = "소셜 로그인 요청")
public record Oauth(
@Schema(description = "OAuth id")
@NotBlank(message = "OAuth id는 필수 입력값입니다.")
String oauthId,
@Schema(description = "OIDC 토큰")
@NotBlank(message = "OIDC 토큰은 필수 입력값입니다.")
String idToken
) {
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ public String password() {
}
}

public record OauthInfo(String idToken, String name, String username, String phone, String code) {
}

@Schema(name = "signUpReqGeneral", title = "일반 회원가입 요청 DTO")
public record General(
@Schema(description = "아이디", example = "pennyway")
Expand Down Expand Up @@ -90,8 +93,47 @@ public Info toInfo() {

@Schema(title = "소셜 회원가입 요청 DTO")
public record Oauth(

@Schema(description = "OIDC 토큰")
@NotBlank(message = "OIDC 토큰은 필수 입력값입니다.")
String idToken,
@Schema(description = "이름", example = "페니웨이")
@NotBlank(message = "이름을 입력해주세요")
@Pattern(regexp = "^[가-힣a-zA-Z]{2,20}$", message = "2~20자의 한글, 영문 대/소문자만 사용 가능합니다.")
String name,
@Schema(description = "아이디", example = "pennyway")
@NotBlank(message = "아이디를 입력해주세요")
@Pattern(regexp = "^[a-z-_.]{5,20}$", message = "5~20자의 영문 소문자, -, _, . 만 사용 가능합니다.")
String username,
@Schema(description = "전화번호", example = "010-1234-5678")
@NotBlank(message = "전화번호를 입력해주세요")
@Pattern(regexp = "^01[01]-\\d{4}-\\d{4}$", message = "전화번호 형식이 올바르지 않습니다.")
String phone,
@Schema(description = "6자리 정수 인증번호", example = "123456")
@NotBlank(message = "인증번호는 필수입니다.")
@Pattern(regexp = "^\\d{6}$", message = "인증번호는 6자리 숫자여야 합니다.")
String code
) {
public OauthInfo toOauthInfo() {
return new OauthInfo(idToken, name, username, phone, code);
}
}

@Schema(title = "소셜 회원가입(기존 계정 존재) 요청 DTO")
public record SyncWithAuth(
@Schema(description = "OIDC 토큰")
@NotBlank(message = "OIDC 토큰은 필수 입력값입니다.")
String idToken,
@Schema(description = "전화번호", example = "010-1234-5678")
@NotBlank(message = "전화번호를 입력해주세요")
@Pattern(regexp = "^01[01]-\\d{4}-\\d{4}$", message = "전화번호 형식이 올바르지 않습니다.")
String phone,
@Schema(description = "6자리 정수 인증번호", example = "123456")
@NotBlank(message = "인증번호는 필수입니다.")
@Pattern(regexp = "^\\d{6}$", message = "인증번호는 6자리 숫자여야 합니다.")
String code
) {
public OauthInfo toOauthInfo() {
return new OauthInfo(idToken, null, null, phone, code);
}
}
}
Loading

0 comments on commit 9b7bde2

Please sign in to comment.