Skip to content

Commit

Permalink
feat: OIDC 소셜 로그인 구현 (#119)
Browse files Browse the repository at this point in the history
* chore: 시큐리티 form 로그인 및 세션 등 비활성화 처리

* chore: oauth 의존성 추가

* chore: 시큐리티 yml 파일 작성

* feat: OAuth 정보로 조회하는 메서드 추가

* feat: OAuth 정보 필드를 멤버 엔티티에 추가

* feat: 게스트 멤버 유저 생성 메서드 구현

* chore: 공통 프로파일 분리

* chore: jwt 의존성 추가

* chore: 시큐리티 전용 설정 파일 분리

* feat: 시큐리티 yml에 만료시간 및 발급자 추가

* feat: JwtProperties 추가

* feat: JwtTokenProvider 구현

* feat: 에러코드 추가

* feat: 리프레시 토큰 및 레포지터리 임시 구현

* feat: OIDC 유저 서비스 구현

* feat: 성공 핸들러 임시 추가

* feat: 엑세스 토큰 DTO 추가

* feat: 시큐리티 OAuth 설정 반영

* fix: MemberRole이 게스트 상태를 가지도록 수정

* feat: 게스트 역할 여부 확인을 위한 커스텀 OIDC 유저 구현

* feat: 표준 스코프 스펙과 관계없이 userinfo를 가져오도록 빈 스코프로 설정

* refactor: OIDC 유저 서비스가 OidcUser 인터페이스에 의존하도록 변경

* feat: 시큐리티 관련 상수 클래스 추가

* feat: OIDC 로그인 성공 핸들러 구현

* refactor: 상수 클래스로 대체

* docs: 주석 변경

* fix: 토큰 키가 필요한 시점에 초기화되도록 수정

* fix: 오타 및 포매팅 수정

* refactor: 테스트 시 JPA 관련 빈 로딩 제외를 위해 설정 클래스 분리

* refactor: 리프레시 토큰의 ttl을 인자로 받도록 수정

* test: 리프레시 토큰 레포지터리 테스트 작성

* refactor: token 패키지를 auth 패키지로 변경

* feat: 마지막 로그인 시간 업데이트 기능 구현

* feat: 게스트 멤버 회원가입 구현

* test: 멤버 도메인 유닛 테스트 변경 및 회원가입 테스트 작성

* feat: 회원가입 서비스 및 컨트롤러 구현

* refactor: 필터와 토큰 간 결합도 낮추기 위해 프로바이더와 서비스 분리

* refactor: 리다이렉트 경로 및 상수 수정

* feat: JWT 인증 필터 구현

* feat: 토큰 재발급 로직 구현

* feat: 멤버 가입 DTO 추가

* feat: 토큰 만료 여부 확인 메서드 구현

* style: spotlessApply

* fix: 토큰이 있는 경우에만 체크하도록 수정

* refactor: 로컬 환경에서는 디비 스키마 항상 새로 생성

* refactor: 프론트 회원가입 페이지 url 반영

* feat: 허용된 URL 외에는 인증 수행하도록 변경

* feat: JWT 인증 필터 추가

* refactor: 회원가입 필요 여부는 헤더에 담아서 응답하도록 변경

* feat: atk, rtk 둘 다 만료 아닌 경우 rtk 재발급하는 로직 추가

* refactor: 커스텀 헤더 이름 변경

* refactor: 상수 클래스의 인스턴스화 막기

* refactor: 에러코드 위치 변경

* style: 포매팅 수정

* docs: 인증 API 스웨거 태그 추가

* chore: 환경변수 디폴트값 지정

* fix: 누락된 쉼표 추가

* style: 개행 추가

* fix: 컨트롤러 테스트에서 시큐리티 설정 제외

* feat: 애플 키파일로 시크릿 만드는 로직 임시 구현

* chore: 애플 프로바이더 및 클라이언트 정보 추가

* feat: 애플 요청 엔티티 컨버터 구현

* feat: 애플 요청 엔티티 컨버터 V2 구현

* feat: 애플 프로퍼티 로드하는 로직 구현

* docs: TODO 추가

* feat: 에러코드 추가

* feat: OIDC 로그인 실패 핸들러 추가

* feat: 커스텀 OAuth 엑세스 토큰 클라이언트 구현

* refactor: 한 라인에 한번의 작업만 수행하도록 개선

* fix: 오타 수정

* feat: 컨트롤러 테스트 임시 비활성화 처리

* refactor: 컴포넌트 추가

* feat: 시큐리티 설정 추가

* style: spotless 적용

* chore: 엑세스 토큰 시간 변경

* fix: OAuth 관련 설정 비활성화

* fix: OAuth 관련 환경변수 디폴트값 설정

* fix: 필터 모킹 추가

* docs: 주석 수정

* style: spotless 적용
  • Loading branch information
uwoobeat authored and kdomo committed Jan 15, 2024
1 parent 0986204 commit a435062
Show file tree
Hide file tree
Showing 38 changed files with 1,170 additions and 27 deletions.
13 changes: 11 additions & 2 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,16 @@ dependencies {
// Spring Security
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.security:spring-security-test'
implementation 'org.springframework.security:spring-security-oauth2-client'
implementation 'org.springframework.security:spring-security-oauth2-jose'

// 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'

// Apple Login
implementation 'org.bouncycastle:bcpkix-jdk18on:1.72'

// Querydsl
implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
Expand All @@ -57,9 +67,8 @@ dependencies {
// AWS
implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE'

// Test
testImplementation 'org.springframework.boot:spring-boot-starter-test'

// test lombok
testCompileOnly 'org.projectlombok:lombok'
testAnnotationProcessor 'org.projectlombok:lombok'
}
Expand Down
2 changes: 0 additions & 2 deletions src/main/java/com/depromeet/TenminuteApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,8 @@

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;

@SpringBootApplication
@EnableJpaAuditing
public class TenminuteApplication {
public static void main(String[] args) {
SpringApplication.run(TenminuteApplication.class, args);
Expand Down
31 changes: 31 additions & 0 deletions src/main/java/com/depromeet/domain/auth/api/AuthController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package com.depromeet.domain.auth.api;

import com.depromeet.domain.auth.application.AuthService;
import com.depromeet.domain.auth.application.JwtTokenService;
import com.depromeet.domain.auth.dto.MemberRegisterRequest;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@Tag(name = "1. [인증]", description = "인증 관련 API")
@RestController
@RequestMapping("/auth")
@RequiredArgsConstructor
public class AuthController {

private final JwtTokenService jwtTokenService;
private final AuthService authService;

@Operation(summary = "회원가입", description = "회원가입을 진행합니다.")
@PostMapping("/register")
public ResponseEntity<Void> memberRegister(@Valid @RequestBody MemberRegisterRequest request) {
authService.registerMember(request);
return ResponseEntity.ok().build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.depromeet.domain.auth.application;

import com.depromeet.domain.auth.dto.MemberRegisterRequest;
import com.depromeet.domain.member.dao.MemberRepository;
import com.depromeet.domain.member.domain.Member;
import com.depromeet.global.util.MemberUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

@Slf4j
@Service
@RequiredArgsConstructor
public class AuthService {

private final MemberRepository memberRepository;
private final MemberUtil memberUtil;

public void registerMember(MemberRegisterRequest request) {
final Member member = memberUtil.getCurrentMember();
member.register(request.nickname());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package com.depromeet.domain.auth.application;

import com.depromeet.domain.auth.dao.RefreshTokenRepository;
import com.depromeet.domain.auth.domain.RefreshToken;
import com.depromeet.domain.auth.dto.AccessToken;
import com.depromeet.domain.member.dao.MemberRepository;
import com.depromeet.domain.member.domain.Member;
import com.depromeet.domain.member.domain.MemberRole;
import com.depromeet.global.config.security.PrincipalDetails;
import com.depromeet.global.security.JwtTokenProvider;
import java.util.NoSuchElementException;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class JwtTokenService {

private final JwtTokenProvider jwtTokenProvider;
private final MemberRepository memberRepository;
private final RefreshTokenRepository refreshTokenRepository;

public String createAccessToken(Long memberId, MemberRole memberRole) {
return jwtTokenProvider.generateAccessToken(memberId, memberRole);
}

public String createRefreshToken(Long memberId) {
String token = jwtTokenProvider.generateRefreshToken(memberId);
RefreshToken refreshToken =
RefreshToken.builder()
.memberId(memberId)
.token(token)
.ttl(jwtTokenProvider.getRefreshTokenExpirationTime())
.build();
refreshTokenRepository.save(refreshToken);
return token;
}

public String reissueAccessToken(String refreshToken) {
Member member = getMemberFrom(refreshToken);
return createAccessToken(member.getId(), member.getRole());
}

public String reissueAccessToken(AccessToken accessToken) {
return createAccessToken(accessToken.memberId(), accessToken.memberRole());
}

public String reissueRefreshToken(String refreshToken) {
Member member = getMemberFrom(refreshToken);
return createRefreshToken(member.getId());
}

public String reissueRefreshToken(AccessToken accessToken) {
return createRefreshToken(accessToken.memberId());
}

private Member getMemberFrom(String refreshToken) throws NoSuchElementException {
Long memberId = jwtTokenProvider.parseRefreshToken(refreshToken);
return memberRepository.findById(memberId).orElseThrow();
}

public Authentication getAuthentication(String accessToken) {
AccessToken parsedAccessToken = jwtTokenProvider.parseAccessToken(accessToken);

UserDetails userDetails =
new PrincipalDetails(
parsedAccessToken.memberId(), parsedAccessToken.memberRole().name());

return new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
}

public boolean isAccessTokenExpired(String accessToken) {
return jwtTokenProvider.isAccessTokenExpired(accessToken);
}

public boolean isRefreshTokenExpired(String refreshToken) {
return jwtTokenProvider.isRefreshTokenExpired(refreshToken);
}

public AccessToken parseAccessToken(String accessToken) {
return jwtTokenProvider.parseAccessToken(accessToken);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.depromeet.domain.auth.dao;

import com.depromeet.domain.auth.domain.RefreshToken;
import org.springframework.data.repository.CrudRepository;

public interface RefreshTokenRepository extends CrudRepository<RefreshToken, Long> {}
23 changes: 23 additions & 0 deletions src/main/java/com/depromeet/domain/auth/domain/RefreshToken.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.depromeet.domain.auth.domain;

import lombok.Builder;
import lombok.Getter;
import org.springframework.data.annotation.Id;
import org.springframework.data.redis.core.RedisHash;
import org.springframework.data.redis.core.TimeToLive;

@Getter
@RedisHash(value = "refreshToken")
public class RefreshToken {

@Id private Long memberId;
private String token;
@TimeToLive private long ttl;

@Builder
public RefreshToken(Long memberId, String token, long ttl) {
this.memberId = memberId;
this.token = token;
this.ttl = ttl;
}
}
5 changes: 5 additions & 0 deletions src/main/java/com/depromeet/domain/auth/dto/AccessToken.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.depromeet.domain.auth.dto;

import com.depromeet.domain.member.domain.MemberRole;

public record AccessToken(Long memberId, MemberRole memberRole) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.depromeet.domain.auth.dto;

public record MemberRegisterRequest(String nickname) {
// TODO: Add validation
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
package com.depromeet.domain.member.dao;

import com.depromeet.domain.member.domain.Member;
import com.depromeet.domain.member.domain.OauthInfo;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;

public interface MemberRepository extends JpaRepository<Member, Long> {}
public interface MemberRepository extends JpaRepository<Member, Long> {

Optional<Member> findByOauthInfo(OauthInfo oauthInfo);
}
33 changes: 33 additions & 0 deletions src/main/java/com/depromeet/domain/member/domain/Member.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import com.depromeet.domain.common.model.BaseTimeEntity;
import com.depromeet.domain.mission.domain.Mission;
import com.depromeet.global.error.exception.CustomException;
import com.depromeet.global.error.exception.ErrorCode;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import jakarta.persistence.Embedded;
Expand Down Expand Up @@ -32,6 +34,8 @@ public class Member extends BaseTimeEntity {

@Embedded private Profile profile;

@Embedded private OauthInfo oauthInfo;

@Enumerated(EnumType.STRING)
private MemberStatus status;

Expand All @@ -49,17 +53,28 @@ public class Member extends BaseTimeEntity {
@Builder(access = AccessLevel.PRIVATE)
private Member(
Profile profile,
OauthInfo oauthInfo,
MemberStatus status,
MemberRole role,
MemberVisibility visibility,
LocalDateTime lastLoginAt) {
this.profile = profile;
this.oauthInfo = oauthInfo;
this.status = status;
this.role = role;
this.visibility = visibility;
this.lastLoginAt = lastLoginAt;
}

public static Member createGuestMember(OauthInfo oauthInfo) {
return Member.builder()
.oauthInfo(oauthInfo)
.status(MemberStatus.NORMAL)
.role(MemberRole.GUEST)
.visibility(MemberVisibility.PUBLIC)
.build();
}

public static Member createNormalMember(Profile profile) {
return Member.builder()
.profile(profile)
Expand All @@ -69,4 +84,22 @@ public static Member createNormalMember(Profile profile) {
.lastLoginAt(LocalDateTime.now())
.build();
}

public void updateLastLoginAt(LocalDateTime lastLoginAt) {
this.lastLoginAt = lastLoginAt;
}

public void register(String nickname) {
validateRegisterAvailable();
// TODO: Profile 클래스를 제거하고 Member 클래스 필드로 변경
// TODO: profileImageUrl이 항상 null이 되는 문제 해결
this.profile = new Profile(nickname, null);
this.role = MemberRole.USER;
}

private void validateRegisterAvailable() {
if (role != MemberRole.GUEST) {
throw new CustomException(ErrorCode.MEMBER_ALREADY_REGISTERED);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@
@Getter
@AllArgsConstructor
public enum MemberRole {
USER("USER"),
ADMIN("ADMIN");
GUEST("ROLE_GUEST"),
USER("ROLE_USER"),
ADMIN("ROLE_ADMIN");

private final String value;
}
18 changes: 18 additions & 0 deletions src/main/java/com/depromeet/domain/member/domain/OauthInfo.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.depromeet.domain.member.domain;

import jakarta.persistence.Embeddable;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Embeddable
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class OauthInfo {

private String oauthId;
private String oauthProvider;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.depromeet.global.common.constants;

public final class SecurityConstants {

private SecurityConstants() {}

public static final String TOKEN_ROLE_NAME = "role";
public static final String TOKEN_PREFIX = "Bearer ";
public static final String ACCESS_TOKEN_HEADER = "Authorization";
public static final String REFRESH_TOKEN_HEADER = "Refresh-Token";
public static final String REGISTER_REQUIRED_HEADER = "Registration-Required";
}
Loading

0 comments on commit a435062

Please sign in to comment.