Skip to content

Commit 6133687

Browse files
authored
Merge pull request #17 from CAPS-DGU/feat/#16-security
[feat] #16 Security 리팩토링 및 onboarding 항목 추가
2 parents 45117d7 + 78ba625 commit 6133687

30 files changed

+546
-537
lines changed

src/main/java/kr/dgucaps/caps/domain/member/controller/AuthApi.java renamed to src/main/java/kr/dgucaps/caps/domain/auth/controller/AuthApi.java

Lines changed: 10 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
package kr.dgucaps.caps.domain.member.controller;
1+
package kr.dgucaps.caps.domain.auth.controller;
22

33
import io.swagger.v3.oas.annotations.Operation;
44
import io.swagger.v3.oas.annotations.media.Content;
@@ -9,12 +9,10 @@
99
import jakarta.servlet.http.HttpServletResponse;
1010
import jakarta.validation.Valid;
1111
import kr.dgucaps.caps.domain.member.dto.request.CompleteRegistrationRequest;
12-
import kr.dgucaps.caps.domain.member.dto.response.AuthInfoResponse;
1312
import kr.dgucaps.caps.domain.member.dto.response.MemberInfoResponse;
14-
import kr.dgucaps.caps.domain.member.entity.Member;
13+
import kr.dgucaps.caps.global.annotation.Auth;
1514
import kr.dgucaps.caps.global.common.SuccessResponse;
1615
import org.springframework.http.ResponseEntity;
17-
import org.springframework.security.core.annotation.AuthenticationPrincipal;
1816
import org.springframework.web.bind.annotation.CookieValue;
1917
import org.springframework.web.bind.annotation.RequestBody;
2018

@@ -29,8 +27,9 @@ public interface AuthApi {
2927
content = @Content(mediaType = "application/json",
3028
schema = @Schema(implementation = MemberInfoResponse.class))
3129
)
32-
ResponseEntity<SuccessResponse<?>> completeRegistration(@AuthenticationPrincipal(expression = "member") Member member,
33-
@RequestBody @Valid CompleteRegistrationRequest request);
30+
ResponseEntity<SuccessResponse<?>> completeRegistration(@Auth Long memberId,
31+
@RequestBody @Valid CompleteRegistrationRequest request,
32+
HttpServletResponse response);
3433

3534
@Operation(
3635
summary = "로그아웃",
@@ -39,7 +38,7 @@ ResponseEntity<SuccessResponse<?>> completeRegistration(@AuthenticationPrincipal
3938
"클라이언트의 액세스, 리프레쉬 토큰을 삭제해야합니다."
4039
)
4140
@ApiResponse(responseCode = "204", description = "로그아웃 성공")
42-
ResponseEntity<SuccessResponse<?>> logout(@AuthenticationPrincipal(expression = "member") Member member);
41+
ResponseEntity<SuccessResponse<?>> logout(@Auth Long memberId, HttpServletResponse response);
4342

4443
@Operation(
4544
summary = "토큰 재발급",
@@ -48,22 +47,10 @@ ResponseEntity<SuccessResponse<?>> completeRegistration(@AuthenticationPrincipal
4847
"재발급한 토큰은 쿠키로 저장됩니다."
4948
)
5049
@ApiResponses({
51-
@ApiResponse(responseCode = "200", description = "토큰 재발급 성공"),
52-
@ApiResponse(responseCode = "401", description = "리프레쉬 토큰이 유효하지 않음"),
53-
@ApiResponse(responseCode = "500", description = "리프레쉬 토큰이 파싱 실패"),
50+
@ApiResponse(responseCode = "200", description = "토큰 재발급 성공"),
51+
@ApiResponse(responseCode = "401", description = "리프레쉬 토큰이 유효하지 않음"),
52+
@ApiResponse(responseCode = "500", description = "리프레쉬 토큰이 파싱 실패"),
5453
})
5554
ResponseEntity<SuccessResponse<?>> reissueToken(@CookieValue(value = "refreshToken", required = false) String refreshToken,
56-
HttpServletResponse response);
57-
58-
@Operation(
59-
summary = "인증 정보 조회",
60-
description = "현재 로그인한 사용자의 인증 정보 및 온보딩 완료 여부를 조회합니다."
61-
)
62-
@ApiResponses({
63-
@ApiResponse(responseCode = "200", description = "인증 정보 조회 성공",
64-
content = @Content(mediaType = "application/json",
65-
schema = @Schema(implementation = AuthInfoResponse.class))),
66-
@ApiResponse(responseCode = "401", description = "인증 정보가 없음")
67-
})
68-
ResponseEntity<SuccessResponse<?>> getAuthInfo(@AuthenticationPrincipal(expression = "member") Member member);
55+
HttpServletResponse response);
6956
}

src/main/java/kr/dgucaps/caps/domain/member/controller/AuthController.java renamed to src/main/java/kr/dgucaps/caps/domain/auth/controller/AuthController.java

Lines changed: 11 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,13 @@
1-
package kr.dgucaps.caps.domain.member.controller;
1+
package kr.dgucaps.caps.domain.auth.controller;
22

33
import jakarta.servlet.http.HttpServletResponse;
44
import jakarta.validation.Valid;
5+
import kr.dgucaps.caps.domain.auth.service.AuthService;
56
import kr.dgucaps.caps.domain.member.dto.request.CompleteRegistrationRequest;
6-
import kr.dgucaps.caps.domain.member.entity.Member;
7-
import kr.dgucaps.caps.domain.member.service.AuthService;
8-
import kr.dgucaps.caps.domain.member.service.TokenService;
7+
import kr.dgucaps.caps.global.annotation.Auth;
98
import kr.dgucaps.caps.global.common.SuccessResponse;
109
import lombok.RequiredArgsConstructor;
1110
import org.springframework.http.ResponseEntity;
12-
import org.springframework.security.core.annotation.AuthenticationPrincipal;
1311
import org.springframework.web.bind.annotation.*;
1412

1513
@RestController
@@ -18,32 +16,28 @@
1816
public class AuthController implements AuthApi {
1917

2018
private final AuthService authService;
21-
private final TokenService tokenService;
2219

2320
// 회원가입 후 추가 정보 입력
2421
@PatchMapping("/complete-registration")
25-
public ResponseEntity<SuccessResponse<?>> completeRegistration(@AuthenticationPrincipal(expression = "member") Member member,
26-
@RequestBody @Valid CompleteRegistrationRequest request) {
27-
return SuccessResponse.ok(authService.completeRegistration(member, request));
22+
public ResponseEntity<SuccessResponse<?>> completeRegistration(@Auth Long memberId,
23+
@RequestBody @Valid CompleteRegistrationRequest request,
24+
HttpServletResponse response) {
25+
authService.completeRegistration(memberId, request, response);
26+
return SuccessResponse.noContent();
2827
}
2928

3029
// 로그아웃
3130
@PostMapping("/logout")
32-
public ResponseEntity<SuccessResponse<?>> logout(@AuthenticationPrincipal(expression = "member") Member member) {
33-
tokenService.logout(member);
31+
public ResponseEntity<SuccessResponse<?>> logout(@Auth Long memberId, HttpServletResponse response) {
32+
authService.logout(memberId, response);
3433
return SuccessResponse.noContent();
3534
}
3635

3736
// 리프레쉬 토큰 재발급
3837
@PostMapping("/reissue")
3938
public ResponseEntity<SuccessResponse<?>> reissueToken(@CookieValue(value = "refreshToken", required = false) String refreshToken,
4039
HttpServletResponse response) {
41-
tokenService.reissue(refreshToken, response);
40+
authService.reissueToken(refreshToken, response);
4241
return SuccessResponse.noContent();
4342
}
44-
45-
@GetMapping("/me")
46-
public ResponseEntity<SuccessResponse<?>> getAuthInfo(@AuthenticationPrincipal(expression = "member") Member member) {
47-
return SuccessResponse.ok(authService.getAuthInfo(member));
48-
}
4943
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package kr.dgucaps.caps.domain.auth.dto;
2+
3+
import kr.dgucaps.caps.domain.member.entity.Role;
4+
import lombok.Builder;
5+
6+
@Builder
7+
public record AuthDto(
8+
long memberId,
9+
String oAuthId,
10+
String name,
11+
Role role
12+
) {
13+
public static AuthDto of(long memberId, String providerId, String name, Role role) {
14+
return AuthDto.builder()
15+
.memberId(memberId)
16+
.oAuthId(providerId)
17+
.name(name)
18+
.role(role)
19+
.build();
20+
}
21+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package kr.dgucaps.caps.domain.auth.dto;
2+
3+
import org.springframework.security.core.GrantedAuthority;
4+
import org.springframework.security.oauth2.core.user.OAuth2User;
5+
6+
import java.util.Collection;
7+
import java.util.Map;
8+
9+
public record CustomOAuth2User(
10+
AuthDto authDto
11+
) implements OAuth2User {
12+
@Override
13+
public Map<String, Object> getAttributes() {
14+
return null;
15+
}
16+
17+
@Override
18+
public Collection<? extends GrantedAuthority> getAuthorities() {
19+
return null;
20+
}
21+
22+
@Override
23+
public String getName() {
24+
return String.valueOf(authDto.memberId());
25+
}
26+
27+
public String getOAuthId() {
28+
return authDto.oAuthId();
29+
}
30+
31+
public String getUserName() {
32+
return authDto.name();
33+
}
34+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package kr.dgucaps.caps.domain.auth.dto;
2+
3+
import java.util.Map;
4+
5+
public record KakaoResponseDto(
6+
Map<String, Object> attribute
7+
) implements OAuth2ResponseDto {
8+
private Map<String, Object> getKakaoAccount() {
9+
return (Map<String, Object>) attribute.get("kakao_account");
10+
}
11+
12+
private Map<String, Object> getKakaoProfile() {
13+
return (Map<String, Object>) getKakaoAccount().get("profile");
14+
}
15+
16+
@Override
17+
public String getProviderId() {
18+
return attribute.get("id").toString();
19+
}
20+
21+
@Override
22+
public String getName() {
23+
Map<String, Object> kakaoProfile = (Map<String, Object>) getKakaoAccount().get("profile");
24+
return kakaoProfile.get("nickname").toString();
25+
}
26+
27+
@Override
28+
public String getEmail() {
29+
return getKakaoAccount().get("email").toString();
30+
}
31+
32+
@Override
33+
public String getProfileImageUrl() {
34+
return getKakaoProfile().get("profile_image_url").toString();
35+
}
36+
}
37+
38+
/*
39+
* Kakao 응답 예시:
40+
* {
41+
* "id": 123456789,
42+
* "kakao_account": {
43+
* "profile": {
44+
* "profile_image_url": "http://yyy.kakao.com/dn/.../img_640x640.jpg"
45+
* },
46+
* "name": "홍길동",
47+
* "email": "sample@sample.com",
48+
* "phone_number": "+82 010-1234-5678"
49+
* }
50+
* }
51+
*/
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package kr.dgucaps.caps.domain.auth.dto;
2+
3+
public interface OAuth2ResponseDto {
4+
5+
String getProviderId();
6+
7+
String getName();
8+
9+
String getEmail();
10+
11+
String getProfileImageUrl();
12+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package kr.dgucaps.caps.domain.auth.service;
2+
3+
import jakarta.servlet.http.HttpServletResponse;
4+
import kr.dgucaps.caps.domain.member.dto.request.CompleteRegistrationRequest;
5+
import kr.dgucaps.caps.domain.member.entity.Member;
6+
import kr.dgucaps.caps.domain.member.repository.MemberRepository;
7+
import kr.dgucaps.caps.domain.redis.entity.RefreshToken;
8+
import kr.dgucaps.caps.domain.redis.repository.RefreshTokenRepository;
9+
import kr.dgucaps.caps.global.config.auth.jwt.JwtProvider;
10+
import kr.dgucaps.caps.global.error.ErrorCode;
11+
import kr.dgucaps.caps.global.error.exception.EntityNotFoundException;
12+
import kr.dgucaps.caps.global.error.exception.UnauthorizedException;
13+
import lombok.RequiredArgsConstructor;
14+
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
15+
import org.springframework.security.core.Authentication;
16+
import org.springframework.stereotype.Service;
17+
import org.springframework.transaction.annotation.Transactional;
18+
import org.springframework.util.StringUtils;
19+
20+
@Service
21+
@RequiredArgsConstructor
22+
@Transactional(readOnly = true)
23+
public class AuthService {
24+
25+
private final JwtProvider jwtProvider;
26+
private final RefreshTokenRepository refreshTokenRepository;
27+
private final MemberRepository memberRepository;
28+
29+
public void reissueToken(String refreshToken, HttpServletResponse response) {
30+
if (!StringUtils.hasText(refreshToken)) {
31+
throw new UnauthorizedException(ErrorCode.REFRESH_TOKEN_NOT_FOUND);
32+
}
33+
jwtProvider.validateRefreshToken(refreshToken);
34+
Authentication authentication = jwtProvider.getAuthentication(refreshToken);
35+
36+
// Redis에 저장된 refreshToken과 비교하여 일치하는지 확인
37+
String memberId = authentication.getName();
38+
RefreshToken refreshTokenEntity = refreshTokenRepository.findById(memberId)
39+
.orElseThrow(() -> new UnauthorizedException(ErrorCode.NOT_MATCH_REFRESH_TOKEN));
40+
if (!refreshTokenEntity.getRefreshToken().equals(refreshToken)) {
41+
throw new UnauthorizedException(ErrorCode.NOT_MATCH_REFRESH_TOKEN);
42+
}
43+
44+
// 새로 accessToken과 refreshToken 발급
45+
String newAccessToken = jwtProvider.generateAccessToken(authentication);
46+
String newRefreshToken = jwtProvider.generateRefreshToken(authentication);
47+
48+
// Redis에 새로 발급한 refreshToken 저장
49+
RefreshToken newRefreshTokenEntity = new RefreshToken(memberId, newRefreshToken);
50+
refreshTokenRepository.save(newRefreshTokenEntity);
51+
52+
jwtProvider.writeTokenCookies(response, newAccessToken, newRefreshToken);
53+
}
54+
55+
public void logout(Long memberId, HttpServletResponse response) {
56+
refreshTokenRepository.deleteById(memberId.toString());
57+
String accessToken = "";
58+
String refreshToken = "";
59+
jwtProvider.writeTokenCookies(response, accessToken, refreshToken);
60+
}
61+
62+
@Transactional
63+
public void completeRegistration(Long memberId, CompleteRegistrationRequest request, HttpServletResponse response) {
64+
Member member = memberRepository.findById(memberId)
65+
.orElseThrow(() -> new EntityNotFoundException(ErrorCode.MEMBER_NOT_FOUND));
66+
member.completeRegistration(request.studentNumber(), request.grade(), request.phoneNumber());
67+
68+
Authentication authentication = new UsernamePasswordAuthenticationToken(String.valueOf(memberId), null, null);
69+
String accessToken = jwtProvider.generateAccessToken(authentication);
70+
String refreshToken = jwtProvider.generateRefreshToken(authentication);
71+
jwtProvider.writeTokenCookies(response, accessToken, refreshToken);
72+
}
73+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package kr.dgucaps.caps.domain.auth.service;
2+
3+
import kr.dgucaps.caps.domain.auth.dto.AuthDto;
4+
import kr.dgucaps.caps.domain.auth.dto.CustomOAuth2User;
5+
import kr.dgucaps.caps.domain.auth.dto.KakaoResponseDto;
6+
import kr.dgucaps.caps.domain.auth.dto.OAuth2ResponseDto;
7+
import kr.dgucaps.caps.domain.member.entity.Member;
8+
import kr.dgucaps.caps.domain.member.repository.MemberRepository;
9+
import lombok.RequiredArgsConstructor;
10+
import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService;
11+
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
12+
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
13+
import org.springframework.security.oauth2.core.user.OAuth2User;
14+
import org.springframework.stereotype.Service;
15+
import org.springframework.transaction.annotation.Transactional;
16+
17+
@Service
18+
@RequiredArgsConstructor
19+
public class CustomOAuth2UserService extends DefaultOAuth2UserService {
20+
21+
private final MemberRepository memberRepository;
22+
23+
@Override
24+
@Transactional
25+
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
26+
OAuth2User oAuth2User = super.loadUser(userRequest);
27+
String registrationId = userRequest.getClientRegistration().getRegistrationId();
28+
OAuth2ResponseDto oAuth2Response;
29+
if (registrationId.equals("kakao")) {
30+
oAuth2Response = new KakaoResponseDto(oAuth2User.getAttributes());
31+
} else {
32+
return null;
33+
}
34+
String oauthId = oAuth2Response.getProviderId();
35+
Member existingMember = memberRepository.findByKakaoId(oauthId);
36+
Member member = null;
37+
if (existingMember == null) {
38+
member = Member.builder()
39+
.kakaoId(oauthId)
40+
.name(oAuth2Response.getName())
41+
.email(oAuth2Response.getEmail())
42+
.profileImageUrl(oAuth2Response.getProfileImageUrl())
43+
.build();
44+
member = memberRepository.save(member);
45+
} else {
46+
member = existingMember;
47+
}
48+
AuthDto authDto = AuthDto.of(member.getId(), oauthId, oAuth2Response.getName(), member.getRole());
49+
return new CustomOAuth2User(authDto);
50+
}
51+
}

0 commit comments

Comments
 (0)