Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,15 @@ dependencies {
// Security
implementation 'org.springframework.boot:spring-boot-starter-security'
testImplementation 'org.springframework.security:spring-security-test'

// Jwt
implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
implementation 'io.jsonwebtoken:jjwt-impl:0.12.3'
implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3'
implementation 'org.springframework.boot:spring-boot-configuration-processor'

// OAuth
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
}

tasks.named('test') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@

import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import umc.domain.member.dto.MemberReqDTO;
import umc.domain.member.dto.MemberResDTO;
import umc.domain.member.exception.code.MemberSuccessCode;
import umc.domain.member.service.MemberService;
import umc.global.apiPayload.ApiResponse;
import umc.global.apiPayload.code.BaseSuccessCode;
import umc.global.security.entity.AuthMember;

@RestController
@RequiredArgsConstructor
Expand All @@ -18,10 +20,11 @@ public class MemberController {
private final MemberService memberService;

@GetMapping("/users/me")
public ApiResponse<MemberResDTO.GetInfo> getInfo(){
Long memberId = 1L; // TODO: memberId from access token
public ApiResponse<MemberResDTO.GetInfo> getInfo(
@AuthenticationPrincipal AuthMember member
){
BaseSuccessCode code = MemberSuccessCode.MEMBER_VIEW;
MemberResDTO.GetInfo response = memberService.getInfo(memberId);
MemberResDTO.GetInfo response = memberService.getInfo(member.getMember().getId());
return ApiResponse.onSuccess(code, response);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,12 @@ public ApiResponse<MemberResDTO.CreateMember> createMember(
BaseSuccessCode code = MemberSuccessCode.MEMBER_CREATED;
return ApiResponse.onSuccess(code, memberService.createMember(request));
}

@PostMapping("/login")
public ApiResponse<MemberResDTO.Login> login(
@RequestBody @Valid MemberReqDTO.Login request
) {
BaseSuccessCode code = MemberSuccessCode.MEMBER_LOGIN;
return ApiResponse.onSuccess(code, memberService.login(request));
}
}
23 changes: 23 additions & 0 deletions src/main/java/umc/domain/member/converter/MemberConverter.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,14 @@
import org.springframework.data.domain.Page;
import umc.domain.member.dto.MemberResDTO;
import umc.domain.member.entity.Member;
import umc.domain.member.enums.Gender;
import umc.domain.member.exception.code.MemberErrorCode;
import umc.domain.mission.entity.Mission;
import umc.domain.store.entity.Region;
import umc.domain.store.entity.Store;
import umc.global.security.dto.OAuthDTO;

import java.time.LocalDate;
import java.util.List;
import java.util.Map;

Expand Down Expand Up @@ -69,4 +72,24 @@ public static MemberResDTO.CreateMember toCreateMember(
.build();
}

public static MemberResDTO.Login toLogin(String accessToken){
return MemberResDTO.Login.builder()
.accessToken(accessToken)
.build();
}

// nullable = false 거나 NullPointerException이 발생 가능한 attribute에는 더미
public static Member toMember(OAuthDTO dto) {
return Member.builder()
.name(dto.getName())
.email(dto.getSocialEmail())
.password("")
.gender(Gender.NONE)
.birth(LocalDate.of(2000, 1, 1))
.address("NONE")
.socialProvider(dto.getSocialProvider())
.socialId(dto.getSocialId())
.currentPoint(0L)
.build();
}
Comment on lines +81 to +94

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

윤샘이 해당 부분에 더미를 두었던 이유를 추적해보며 왜 Member 엔티티에서 nullable=false를 이렇게 많이 지정했지? 생각해보았습니다.

이건 약간 OAuth 로그인 성공 = 서비스 가입 완료라는 시선이 잡혀있어서 그런 것 같습니다! (아님말고)

Image

이미지의 와이어프레임처럼 서비스에서 필요한 추가적인 정보를 받아야 할 경우

방법 A. OAuth 성공 시 Member를 먼저 만든다
→ profileCompleted=false 또는 status=PENDING_PROFILE
→ 추가 정보 입력 완료 시 ACTIVE

방법 B. OAuth 성공 시 Member를 아직 만들지 않는다
→ socialUid/email 등을 임시 토큰이나 세션에 담음
→ 추가 정보 입력까지 끝난 뒤 Member 생성

위의 방법등을 사용하게 됩니다.

이렇게 보면 회원가입 상태 모델링 자체에서 비밀번호/생년월일/주소 없이 소셜 회원을 만들 수 있게 하거나, 아예 생성 시점을 뒤로 미루는 방식도 가능할 것 같은데 이 부분은 어떻게 생각하시는지 궁금합니다!

}
5 changes: 5 additions & 0 deletions src/main/java/umc/domain/member/dto/MemberReqDTO.java
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,9 @@ public record Agree(
@NotNull Boolean marketing
) {}
}

public record Login(
@Email @NotBlank String email,
@NotBlank String paassword
) {}
}
5 changes: 5 additions & 0 deletions src/main/java/umc/domain/member/dto/MemberResDTO.java
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,9 @@ public record HomeMission(
public record CreateMember(
Long memberId
) {}

@Builder
public record Login(
String accessToken
) {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,18 @@
@AllArgsConstructor
public enum MemberErrorCode implements BaseErrorCode {

MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "MEMBER404_1", "해당 사용자를 찾을 수 없습니다."),
EMAIL_ALREADY_EXIST(HttpStatus.CONFLICT, "MEMBER409_1", "해당 이메일은 이미 사용 중 입니다."),
MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND,
"MEMBER404_1",
"해당 사용자를 찾을 수 없습니다."),
EMAIL_ALREADY_EXIST(HttpStatus.CONFLICT,
"MEMBER409_1",
"해당 이메일은 이미 사용 중 입니다."),
LOGIN_FAILED(HttpStatus.UNAUTHORIZED,
"MEMBER401_1",
"이메일 또는 비밀번호가 일치하지 않습니다."),
NOT_SUPPORT_SOCIAL_PROVIDER(HttpStatus.BAD_REQUEST,
"MEMBER400_1",
"지원하지 않는 소셜 로그인 제공자입니다."),
;

private final HttpStatus status;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ public enum MemberSuccessCode implements BaseSuccessCode {
MEMBER_CREATED(HttpStatus.OK,
"MEMBER200_2",
"회원을 성공적으로 생성했습니다."),
MEMBER_LOGIN(HttpStatus.OK,
"MEMBER200_3",
"로그인에 성공했습니다."),
;

private final HttpStatus status;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import org.springframework.data.jpa.repository.JpaRepository;
import umc.domain.member.entity.Member;
import umc.domain.member.enums.SocialProvider;

import java.util.Optional;

Expand All @@ -10,4 +11,6 @@ public interface MemberRepository extends JpaRepository<Member, Long> {
Optional<Member> findByEmail(String email);

boolean existsByEmail(String email);

Optional<Member> findBySocialProviderAndSocialId(SocialProvider provider, String socialId);
}
17 changes: 17 additions & 0 deletions src/main/java/umc/domain/member/service/MemberService.java
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@
import umc.domain.term.exception.TermException;
import umc.domain.term.exception.code.TermErrorCode;
import umc.domain.term.repository.TermRepository;
import umc.global.security.entity.AuthMember;
import umc.global.security.util.JwtUtil;

import java.util.List;
import java.util.Map;
Expand All @@ -55,6 +57,7 @@ public class MemberService {
private final TermRepository termRepository;
private final MemberPreferredCategoryRepository memberPreferredCategoryRepository;
private final TermAgreementRepository termAgreementRepository;
private final JwtUtil jwtUtil;

@Transactional(readOnly = true)
public MemberResDTO.GetInfo getInfo(Long memberId) {
Expand Down Expand Up @@ -199,4 +202,18 @@ private void validateTermExist(
throw new TermException(TermErrorCode.TERM_MASTER_DATA_NOT_FOUND);
}
}

@Transactional
public MemberResDTO.Login login(MemberReqDTO.Login request){
Member member = memberRepository.findByEmail(request.email())
.orElseThrow(() -> new MemberException(MemberErrorCode.LOGIN_FAILED));

if(!passwordEncoder.matches(request.paassword(), member.getPassword())){
throw new MemberException(MemberErrorCode.LOGIN_FAILED);
}

String accessToken = jwtUtil.createAccessToken(new AuthMember(member));

return MemberConverter.toLogin(accessToken);
}
Comment on lines +206 to +218

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

로그인 로직에서 먼저 사용자의 이메일을 검증하고, 비밀번호를 해싱해서 기존 값이랑 검증하는 흐름이 명확하게 잘 보입니다! 또 이메일이 존재하지 않을 때와 비밀번호가 틀렸을 때 에러메시지를 통일시키신 것을 보니 윤샘이 보안도 신경써주신 것 같다고 생각했습니다.

저는 사용자 검증 로직을 Spring Security의 AuthenticationManager.authenticate()에게 위임하는 방식으로 구현했는데, 이 방식을 이용하면 사용자를 검증하는 과정에서 Spring Security가 제공하는 보안 기능을 활용할 수 있다는 장점이 있더라고요! 특히 사용자가 존재하지 않을 때도 내부적으로 비밀번호 비교 연산을 진행해 타이밍 공격을 막을 수 있다고 합니다. (user enumeration이라구 하더라고용)
그리고 UserDetails에서 관리되는 계정 상태도 Spring Security에서 검증하기 때문에 나중에 사용자 계정 잠금이나 비활성화 같은 정책이 추가됐을 때에도 더 유지보수가 편리해진다는 장점도 있습니다!

물론 윤샘처럼 구현하는 것도 가독성 측면에서의 장점도 있는 것 같습니다. 다만 이미 Spring Security를 도입하신 만큼 기능을 활용하는 것도 고려해보시면 좋을 것 같습니다!! 😊

(아 그리고 로그인 요청 dto에 필드값 오타가 있습니다 ㅎㅎ)

}
55 changes: 48 additions & 7 deletions src/main/java/umc/global/config/SecurityConfig.java
Original file line number Diff line number Diff line change
@@ -1,16 +1,25 @@
package umc.global.config;

import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import umc.global.security.exception.CustomAccessDenied;
import umc.global.security.exception.CustomEntryPoint;
import umc.global.security.exception.SecurityErrorResponseWriter;
import umc.global.security.filter.JwtAuthFilter;
import umc.global.security.handler.OAuthSuccessHandler;
import umc.global.security.service.CustomOAuthService;
import umc.global.security.service.CustomUserDetailsService;
import umc.global.security.util.JwtUtil;


@EnableWebSecurity
Expand All @@ -20,40 +29,66 @@ public class SecurityConfig {

private final CustomAccessDenied customAccessDenied;
private final CustomEntryPoint customEntryPoint;
private final JwtUtil jwtUtil;
private final CustomUserDetailsService customUserDetailsService;
private final SecurityErrorResponseWriter securityErrorResponseWriter;
private final OAuthSuccessHandler oAuthSuccessHandler;

private final String[] allowUris = {
// Swagger 허용
"/swagger-ui/**",
"/swagger-resources/**",
"/v3/api-docs/**",

"/public/**"
"/public/**",
"/oauth/**"
};

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
public SecurityFilterChain securityFilterChain(HttpSecurity http, CustomOAuthService customOAuthService) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(requests -> requests
// public API 허용
.requestMatchers(allowUris).permitAll()
// 그 이외 API는 인증 필요
.anyRequest().authenticated()
)
// 폼 로그인
.formLogin(form -> form
.defaultSuccessUrl("/swagger-ui/index.html", true)
.permitAll()
)
// JWT 필터
.addFilterBefore(jwtAuthFilter(), UsernamePasswordAuthenticationFilter.class)
// 세션
.sessionManagement(
AbstractHttpConfigurer::disable
)
.logout(logout -> logout
.logoutUrl("/logout")
.logoutSuccessUrl("/login?logout")
.permitAll()
)
// 예외 상황 핸들러
.exceptionHandling(exception -> exception
.defaultAuthenticationEntryPointFor( // 폼 로그인을 위한 임시 설정
customEntryPoint,
request -> request.getRequestURI().startsWith("/api")
)
.accessDeniedHandler(customAccessDenied)
// .authenticationEntryPoint(customEntryPoint) // 전역 설정
.authenticationEntryPoint(customEntryPoint) // 전역 설정
)
// OAuth
.oauth2Login(oauth -> oauth
.authorizationEndpoint(auth -> auth
.baseUri("/oauth/authorize")
)
.redirectionEndpoint(redirect -> redirect
.baseUri("/oauth/callback/**")
)
// 인증 완료 후 정보 활용
.userInfoEndpoint(userInfo -> userInfo
.userService(customOAuthService)
)
// 성공 시 JWT 토큰 발행할 핸들러
.successHandler(oAuthSuccessHandler)
)
;

Expand All @@ -64,4 +99,10 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}

@Bean
public JwtAuthFilter jwtAuthFilter(){
return new JwtAuthFilter(jwtUtil, customUserDetailsService, securityErrorResponseWriter);
}

}
34 changes: 34 additions & 0 deletions src/main/java/umc/global/security/dto/KakaoDTO.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package umc.global.security.dto;

import lombok.RequiredArgsConstructor;
import umc.domain.member.enums.SocialProvider;

@RequiredArgsConstructor
public class KakaoDTO implements OAuthDTO{

private final String id;
private final String email;
private final String name;

@Override
public SocialProvider getSocialProvider(){
return SocialProvider.KAKAO;
}

@Override
public String getSocialId(){
return id;
}

@Override
public String getSocialEmail(){
return email;
}

@Override
public String getName(){
return name;
}


}
10 changes: 10 additions & 0 deletions src/main/java/umc/global/security/dto/OAuthDTO.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package umc.global.security.dto;

import umc.domain.member.enums.SocialProvider;

public interface OAuthDTO {
SocialProvider getSocialProvider();
String getSocialId();
String getSocialEmail();
String getName();
}
2 changes: 1 addition & 1 deletion src/main/java/umc/global/security/entity/AuthMember.java
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,6 @@ public Collection<? extends GrantedAuthority> getAuthorities(){

@Override
public String getUsername(){
return member.getEmail();
return member.getEmail();
}
}
Loading