-
Notifications
You must be signed in to change notification settings - Fork 0
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: OIDC 소셜 로그인 구현 #119
feat: OIDC 소셜 로그인 구현 #119
Changes from all commits
0853ed5
7a25515
e635180
77081d2
ba5966f
856e18c
b4c12e7
0e00ec5
9e8b639
13a4d22
efb998e
5ee57f3
3163c0f
670938a
7c9596b
cc95255
d6e9b57
6e4872a
42c94d0
cec999e
dbc2b80
2f9d77f
196a616
99b4304
7caea69
cb1f2c5
1c41a12
97ef666
2626b34
d67f593
aadb6d9
342f0e9
3b21529
8626bf6
f1e6284
09ad200
1262542
2ac039b
71ca490
7f4716d
a037b39
ffc20f9
a16eed1
7b9428c
1abbe32
57d9e2b
ce2f65e
94c56e8
b6485ee
cee92ff
598f6c8
9941bce
eca1391
a372f5e
8b879d4
8a22330
5af53b9
31392da
ad83cab
a4d525c
062b912
6eb9673
3a7a197
240a020
f731aa3
3a215d9
e4d657f
6f7952b
19206ab
9cc1563
c93bda6
05b5b6d
fa04350
0c0bd40
7593644
50b84d7
257a560
cb8403f
c750a19
80c1b84
a4e35e4
d65c2ff
fdf3327
d86a75a
5fe494c
42a0515
2f3e26d
aae6caa
a175a27
1195e51
b282147
3fe9905
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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(); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 요기 에러처리 안된건가용? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. AuthenticationEntryPoint에서 잡아줘야 하는데 구현을 안해놨네요~ |
||
} | ||
|
||
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> {} |
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; | ||
} | ||
} |
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); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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; | ||
|
@@ -32,6 +34,8 @@ public class Member extends BaseTimeEntity { | |
|
||
@Embedded private Profile profile; | ||
|
||
@Embedded private OauthInfo oauthInfo; | ||
|
||
@Enumerated(EnumType.STRING) | ||
private MemberStatus status; | ||
|
||
|
@@ -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) | ||
|
@@ -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); | ||
Comment on lines
+94
to
+96
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. object storage에 user default이미지 넣어두고 해당 값 상수로 넣으면 좋을 것 같습니다👍🏻👍🏻 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 아 이거는 나중에 프로바이더에서 제공하는 프로필 이미지 복사해서 object storage에 넣는 로직도 추가 작업할 예정인데, 이렇게 하면 그리고 default 이미지의 경우 프론트에서 가지고 있고 서버에서는 null로 두는게 좋을 것 같습니다! There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 말씀하신대로 디폴트 이미지는 프론트에서 가지고있는게 좋겠네용 |
||
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 |
---|---|---|
@@ -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"; | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
parseRefreshToken 메서드는 customException을 내보내고있는데 throws에 NoSuchElementException은 어디서 나온건가용??
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
아 이 부분 TODO로 해놓고 수정하려고 했는데 빼먹었네요.
orElseThrow()
에서CustomException(ErrorCode.MEMBER_NOT_FOUND
으로 던지는 대신 디폴트 예외엔NoSuchElementException
으로 던지도록 했습니다.이유는 CustomException은 클라이언트한테 어떤 status code를 내려줄 것인지도 결정하고 있는데,
getMemberFrom
에서 발생한 예외는JwtAuthenticationFilter
로 가는데, 여기서 어떤 예외가 발생하든 간에 인증 실패이므로 401을 내려줘야 합니다. 근데MEMBER_NOT_FOUND
를 던지면 404가 나가니까 NoSuchElementException으로 던지고CustomAuthenticationEntryPoint
에서 이걸 잡아서 401로 매핑해줄 계획이었어요.근데 인증 과정에서 발생하는 예외를 잡는 CustomAuthenticationEntryPoint를 아직 구현을 안해서 이 부분은 추가로 작업하도록 할게요.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
NoSuchElementException는 어디서 던지나용 ?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
orElseThrow()
가 디폴트로 던지는 예외가NoSuchElementException
이에용