Skip to content

Commit

Permalink
Merge pull request #22 from Gongjakso/feat/login
Browse files Browse the repository at this point in the history
feat: 카카오 로그인 개발
  • Loading branch information
dl-00-e8 authored Jan 29, 2024
2 parents 729d5df + f546a2d commit 69c2a81
Show file tree
Hide file tree
Showing 30 changed files with 1,187 additions and 20 deletions.
12 changes: 12 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,21 @@ dependencies {
// Redis
implementation 'org.springframework.boot:spring-boot-starter-data-redis'

// JWT
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'

// Swagger UI - spring doc
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.0.2'

// Spring Security + OAuth
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
implementation 'org.springframework.boot:spring-boot-starter-webflux'

// MAC OS
implementation 'io.netty:netty-resolver-dns-native-macos:4.1.68.Final:osx-aarch_64'
}

tasks.named('test') {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,22 +1,59 @@
package com.gongjakso.server.domain.member.controller;

import com.gongjakso.server.domain.member.service.MemberService;
import com.gongjakso.server.domain.member.dto.LoginRes;
import com.gongjakso.server.domain.member.service.AuthService;
import com.gongjakso.server.global.common.ApplicationResponse;
import com.gongjakso.server.global.security.PrincipalDetails;
import com.gongjakso.server.global.security.jwt.dto.TokenDto;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;


@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/auth")
@Tag(name = "Auth", description = "인증 관련 API")
public class AuthController {

private final MemberService memberService;
private final AuthService authService;

@GetMapping("/test")
private String testAPI() {
return "API TEST";
public String test() {
return "test";
}

@Operation(summary = "로그인 API", description = "카카오 로그인 페이지로 리다이렉트되어 카카오 로그인을 수행할 수 있도록 안내")
@PostMapping("/sign-in")
public ApplicationResponse<LoginRes> signIn(@RequestParam(name = "code") String code) {
return ApplicationResponse.ok(authService.signIn(code));
}

@Operation(summary = "로그아웃 API", description = "로그아웃된 JWT 블랙리스트 등록")
@PostMapping("/sign-out")
public ApplicationResponse<Void> signOut(HttpServletRequest request, @AuthenticationPrincipal PrincipalDetails principalDetails) {
String token = request.getHeader("Authorization");
authService.signOut(token, principalDetails.getMember());
return ApplicationResponse.ok();
}

@Operation(summary = "회원탈퇴 API", description = "회원탈퇴 등록")
@PostMapping("/withdrawal")
public ApplicationResponse<Void> withdrawal(@AuthenticationPrincipal PrincipalDetails principalDetails) {
authService.withdrawal(principalDetails.getMember());
return ApplicationResponse.ok();
}

@Operation(summary = "토큰재발급 API", description = "RefreshToken 정보로 요청 시, ")
@GetMapping("/reissue")
public ApplicationResponse<TokenDto> reissue(HttpServletRequest request, @AuthenticationPrincipal PrincipalDetails principalDetails) {
String token = request.getHeader("Authorization");
return ApplicationResponse.ok(authService.reissue(token, principalDetails.getMember()));
}
}

// https://yeees.tistory.com/231
Original file line number Diff line number Diff line change
@@ -1,8 +1,18 @@
package com.gongjakso.server.domain.member.controller;

import com.gongjakso.server.domain.member.dto.MemberReq;
import com.gongjakso.server.domain.member.dto.MemberRes;
import com.gongjakso.server.domain.member.entity.Member;
import com.gongjakso.server.domain.member.service.MemberService;
import com.gongjakso.server.global.common.ApplicationResponse;
import com.gongjakso.server.global.security.PrincipalDetails;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

Expand All @@ -13,4 +23,9 @@
public class MemberController {

private final MemberService memberService;

@PutMapping("")
public ApplicationResponse<MemberRes> update(@AuthenticationPrincipal PrincipalDetails principalDetails, @Valid @RequestBody MemberReq memberReq) {
return ApplicationResponse.ok(memberService.update(principalDetails.getMember(), memberReq));
}
}
41 changes: 41 additions & 0 deletions src/main/java/com/gongjakso/server/domain/member/dto/LoginRes.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package com.gongjakso.server.domain.member.dto;

import com.fasterxml.jackson.annotation.JsonInclude;
import com.gongjakso.server.domain.member.entity.Member;
import com.gongjakso.server.domain.member.enumerate.LoginType;
import com.gongjakso.server.domain.member.enumerate.MemberType;
import com.gongjakso.server.global.security.jwt.dto.TokenDto;
import jakarta.validation.constraints.NotNull;
import lombok.Builder;

@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
public record LoginRes(
@NotNull Long memberId,
@NotNull String email,
@NotNull String name,
String profileUrl,
MemberType memberType,
LoginType loginType,
String status,
String major,
String job,
@NotNull String accessToken,
@NotNull String refreshToken
) {
public static LoginRes of(Member member, TokenDto tokenDto) {
return LoginRes.builder()
.memberId(member.getMemberId())
.email(member.getEmail())
.name(member.getName())
.profileUrl(member.getProfileUrl())
.memberType(member.getMemberType())
.loginType(member.getLoginType())
.status(member.getStatus())
.major(member.getMajor())
.job(member.getJob())
.accessToken(tokenDto.accessToken())
.refreshToken(tokenDto.refreshToken())
.build();
}
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
package com.gongjakso.server.domain.member.dto;

import jakarta.validation.constraints.NotNull;
import lombok.Builder;

@Builder
public record MemberReq(String email,
String name) {
public record MemberReq(@NotNull String name,
String status,
String major,
String job) {
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,37 @@
package com.gongjakso.server.domain.member.dto;

public record MemberRes() {
import com.fasterxml.jackson.annotation.JsonInclude;
import com.gongjakso.server.domain.member.entity.Member;
import com.gongjakso.server.domain.member.enumerate.LoginType;
import com.gongjakso.server.domain.member.enumerate.MemberType;
import jakarta.validation.constraints.NotNull;
import lombok.Builder;

@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
public record MemberRes(
@NotNull Long memberId,
@NotNull String email,
@NotNull String name,
String profileUrl,
MemberType memberType,
LoginType loginType,
String status,
String major,
String job
) {

public static MemberRes of(Member member) {
return MemberRes.builder()
.memberId(member.getMemberId())
.email(member.getEmail())
.name(member.getName())
.profileUrl(member.getProfileUrl())
.memberType(member.getMemberType())
.loginType(member.getLoginType())
.status(member.getStatus())
.major(member.getMajor())
.job(member.getJob())
.build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,12 @@
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.SQLDelete;

@Getter
@Entity
@Table(name = "member")
@SQLDelete(sql = "UPDATE member SET deleted_at = NOW() where member_id = ?")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Member extends BaseTimeEntity {

Expand Down Expand Up @@ -53,11 +55,22 @@ public class Member extends BaseTimeEntity {

public void update(MemberReq memberReq) {
this.name = memberReq.name();
this.status = memberReq.status();
this.major = memberReq.major();
this.job = memberReq.job();
}

@Builder
public Member(Long memberId, String email) {
public Member(Long memberId, String email, String password, String name, String profileUrl, String memberType, String loginType, String status, String major, String job) {
this.memberId = memberId;
this.email = email;
this.password = password;
this.name = name;
this.profileUrl = profileUrl;
this.memberType = MemberType.valueOf(memberType);
this.loginType = LoginType.valueOf(loginType);
this.status = status;
this.major = major;
this.job = job;
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
package com.gongjakso.server.domain.member.enumerate;

import lombok.Getter;
import lombok.RequiredArgsConstructor;

@Getter
@RequiredArgsConstructor
public enum MemberType {
GENERAL, ADMIN
GENERAL("ROLE_GENERAL", "일반"),
ADMIN("ROLE_ADMIN", "관리자");

private final String role;
private final String title;
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
package com.gongjakso.server.domain.member.repository;

import com.gongjakso.server.domain.member.entity.Member;
import com.gongjakso.server.domain.member.enumerate.MemberType;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface MemberRepository extends JpaRepository<Member, Long> {

Optional<Member> findMemberByEmailAndDeletedAtIsNull(String email);

Optional<Member> findMemberByEmailAndMemberTypeAndDeletedAtIsNull(String email, MemberType memberType);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package com.gongjakso.server.domain.member.service;

import com.gongjakso.server.domain.member.dto.LoginRes;
import com.gongjakso.server.domain.member.entity.Member;
import com.gongjakso.server.domain.member.repository.MemberRepository;
import com.gongjakso.server.global.exception.ApplicationException;
import com.gongjakso.server.global.exception.ErrorCode;
import com.gongjakso.server.global.security.jwt.TokenProvider;
import com.gongjakso.server.global.security.jwt.dto.TokenDto;
import com.gongjakso.server.global.security.kakao.KakaoClient;
import com.gongjakso.server.global.security.kakao.dto.KakaoProfile;
import com.gongjakso.server.global.security.kakao.dto.KakaoToken;
import com.gongjakso.server.global.util.redis.RedisClient;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class AuthService {

private final KakaoClient kakaoClient;
private final RedisClient redisClient;
private final TokenProvider tokenProvider;
private final MemberRepository memberRepository;

@Transactional
public LoginRes signIn(String code) {
// Business Logic
// 카카오로 액세스 토큰 요청하기
KakaoToken kakaoAccessToken = kakaoClient.getKakaoAccessToken(code);

// 카카오톡에 있는 사용자 정보 반환
KakaoProfile kakaoProfile = kakaoClient.getMemberInfo(kakaoAccessToken);

// 반환된 정보의 이메일 기반으로 사용자 테이블에서 계정 정보 조회 진행
// 이메일 존재 시 로그인 , 존재하지 않을 경우 회원가입 진행
Member member = memberRepository.findMemberByEmailAndDeletedAtIsNull(kakaoProfile.kakao_account().email()).orElse(null);

if(member == null) {
Member newMember = Member.builder()
.email(kakaoProfile.kakao_account().email())
.name(kakaoProfile.kakao_account().profile().nickname())
.memberType("GENERAL")
.loginType("KAKAO")
.build();

member = memberRepository.save(newMember);
}

TokenDto tokenDto = tokenProvider.createToken(member);

// Redis에 RefreshToken 저장
// TODO: timeout 관련되어 constant가 아닌 tokenProvider 내의 메소드로 관리할 수 있도록 수정 필요
redisClient.setValue(member.getEmail(), tokenDto.refreshToken(), 30 * 24 * 60 * 60 * 1000L);

// Response
return LoginRes.of(member, tokenDto);
}

public void signOut(String token, Member member) {
// Validation
String accessToken = token.substring(7);
tokenProvider.validateToken(accessToken);

// Business Logic - Refresh Token 삭제 및 Access Token 블랙리스트 등록
String key = member.getEmail();
redisClient.deleteValue(key);
redisClient.setValue(accessToken, "logout", tokenProvider.getExpiration(accessToken));

// Response
}

@Transactional
public void withdrawal(Member member) {
// Validation

// Business Logic - 회원 논리적 삭제 진행
memberRepository.delete(member);

// Response
}

public TokenDto reissue(String token, Member member) {
// Validation - RefreshToken 유효성 검증
String refreshToken = token.substring(7);
tokenProvider.validateToken(refreshToken);
String email = tokenProvider.getEmail(refreshToken);
String redisRefreshToken = redisClient.getValue(email);
// 입력받은 refreshToken과 Redis의 RefreshToken 간의 일치 여부 검증
if(refreshToken.isBlank() || redisRefreshToken.isEmpty() || !redisRefreshToken.equals(refreshToken)) {
throw new ApplicationException(ErrorCode.WRONG_TOKEN_EXCEPTION);
}

// Business Logic & Response - Access Token 새로 발급 + Refresh Token의 유효 기간이 Access Token의 유효 기간보다 짧아졌을 경우 Refresh Token도 재발급
return tokenProvider.reissue(member, refreshToken);
}
}
Loading

0 comments on commit 69c2a81

Please sign in to comment.