Skip to content

Commit

Permalink
회원 정보 조회 기능 구현 (#69)
Browse files Browse the repository at this point in the history
* feat: jwt 라이브러리 추가

* fix: merge 과정에서 충돌 이슈 제거

* feat: 토큰 생성, 검증을 하는 TokenProvider 클래스 생성

* feat: SecurityConfig 클래스 추가

* feat: Jwt Filter 적용

* feat: UserDetailService 추가 및 Member에 메서드 추가

* feat: 로그인, 회원가입 시 필요한 dto 클래스 생성

* feat: 유저 회원가입 기능 구현

* feat: 유저 로그인 기능 구현

* feat: 공통 Exception 클래스들 추가

* refactor: LoginSuccessDto 추가

* feat: LoginSuccessDto 추가

* feat: 회원 정보 조회 기능 추가

* feat: 회원 정보 조회 기능 추가

* fix: securityConfig에서 posts url 허용

* feat: 회원 전체 목록 기능 구현

* feat: ErrorCode 적용

* feat: 단위 테스트 작성

* fix: postCount, postLike 값이 null이 들어가는 오류 수정

* refactor: 불필요한 코드 제거

* feat: 회원 로그인, 조회 예외 처리

* fix: signWith() deprecated 수정

* fix: errorCode 중복 네이밍 제거
  • Loading branch information
jschoi-96 authored Feb 15, 2024
1 parent 6436058 commit 95b6963
Show file tree
Hide file tree
Showing 18 changed files with 560 additions and 4 deletions.
11 changes: 10 additions & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,16 @@ repositories {

dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
// implementation 'org.springframework.boot:spring-boot-starter-security'

// security
implementation 'org.springframework.boot:spring-boot-starter-security'

// jwt
implementation 'io.jsonwebtoken:jjwt-api:0.11.2'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.2'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.2'


implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-web'
testImplementation 'org.projectlombok:lombok:1.18.26'
Expand Down
22 changes: 22 additions & 0 deletions src/main/java/balancetalk/global/config/MyUserDetailService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package balancetalk.global.config;

import balancetalk.module.member.domain.MemberRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class MyUserDetailService implements UserDetailsService {

private final MemberRepository memberRepository;

@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return memberRepository.findByEmail(username)
.orElseThrow(() -> new UsernameNotFoundException("회원을 찾을 수 없습니다."));

}
}
44 changes: 44 additions & 0 deletions src/main/java/balancetalk/global/config/SecurityConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package balancetalk.global.config;

import balancetalk.global.jwt.JwtAuthenticationFilter;
import balancetalk.global.jwt.JwtTokenProvider;
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.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfig {

private final JwtTokenProvider jwtTokenProvider;

@Bean
public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.cors(cors -> cors.disable())
// h2 콘솔 사용
.headers(header -> header.frameOptions(frameOption -> frameOption.disable()).disable())
// 세션 사용 X (jwt 사용)
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(request -> request
.requestMatchers("/posts/**", "/members/**", "/h2-console/**").permitAll()
.anyRequest().authenticated()
)
// jwtFilter 먼저 적용
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
4 changes: 4 additions & 0 deletions src/main/java/balancetalk/global/exception/ErrorCode.java
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ public enum ErrorCode {
EXPIRED_POST_DEADLINE(BAD_REQUEST, "투표가 이미 종료된 게시글입니다."),
UNMODIFIABLE_VOTE(BAD_REQUEST, "투표 수정이 불가능한 게시글입니다."),

// 401
MISMATCHED_EMAIL_OR_PASSWORD(UNAUTHORIZED, "이메일 또는 비밀번호가 잘못되었습니다."),
AUTHENTICATION_ERROR(UNAUTHORIZED, "인증 오류가 발생했습니다."),
// 403
FORBIDDEN_COMMENT_MODIFY(FORBIDDEN, "댓글 수정 권한이 없습니다."), // TODO : Spring Security 적용 후 적용 필요
FORBIDDEN_COMMENT_DELETE(FORBIDDEN, "댓글 삭제 권한이 없습니다."), // TODO : SecurityContextHolder 사용 예정
Expand All @@ -25,6 +28,7 @@ public enum ErrorCode {
NOT_FOUND_VOTE(NOT_FOUND, "해당 게시글에서 투표한 기록이 존재하지 않습니다."),
NOT_FOUND_COMMENT(NOT_FOUND, "존재하지 않는 댓글입니다."),


// 409
ALREADY_VOTE(CONFLICT, "투표는 한 번만 가능합니다."),
ALREADY_LIKE_COMMENT(CONFLICT, "이미 추천을 누른 댓글입니다."),
Expand Down
34 changes: 34 additions & 0 deletions src/main/java/balancetalk/global/jwt/JwtAuthenticationFilter.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package balancetalk.global.jwt;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.GenericFilterBean;

import java.io.IOException;

@RequiredArgsConstructor
public class JwtAuthenticationFilter extends GenericFilterBean {

private final JwtTokenProvider jwtTokenProvider;

@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
String token = jwtTokenProvider.resolveToken((HttpServletRequest) request);

// 토큰이 유효할 때
if (token != null && jwtTokenProvider.validateToken(token)) {
// 토큰으로부터 유저 정보를 받는다
Authentication authentication = jwtTokenProvider.getAuthentication(token);
// SecurityContext에 객체 저장
SecurityContextHolder.getContext().setAuthentication(authentication);
}
// 다음 필터로 진행
chain.doFilter(request, response);
}
}
61 changes: 61 additions & 0 deletions src/main/java/balancetalk/global/jwt/JwtTokenProvider.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package balancetalk.global.jwt;

import balancetalk.module.member.domain.Role;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import jakarta.servlet.http.HttpServletRequest;
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.security.core.userdetails.UserDetailsService;
import org.springframework.stereotype.Component;
import java.security.Key;
import java.util.Date;

@Component
@RequiredArgsConstructor
public class JwtTokenProvider {

private Long tokenValidTime = 30 * 60 * 1000L; // 30분 유효 시간
private final UserDetailsService userDetailsService;
private final Key secretKey = Keys.secretKeyFor(SignatureAlgorithm.HS512);

public String createToken(String email, Role role) {
Claims claims = Jwts.claims().setSubject(email); // JWT payload에 저장되는 정보 단위
claims.put("role" , role);
Date now = new Date();
return Jwts.builder()
.setClaims(claims) // 정보 저장
.setIssuedAt(now) // 토큰 발행 시간
.setExpiration(new Date(now.getTime() + tokenValidTime)) // 30분 유효시간 설정
.signWith(secretKey) // 암호화 알고리즘과 secretKey
.compact();
}

// 인증 정보 조회
public Authentication getAuthentication(String token) {
UserDetails userDetails = userDetailsService.loadUserByUsername(this.getUserEmail(token));
return new UsernamePasswordAuthenticationToken(userDetails, userDetails.getAuthorities());
}

// 토큰에서 회원 정보 추출
public String getUserEmail(String token) {
return Jwts.parserBuilder().setSigningKey(secretKey).build().parseClaimsJws(token).getBody().getSubject();
}

// 토큰 유효성, 만료일자 확인
public boolean validateToken(String jwtToken) {
try {
Jws<Claims> claims = Jwts.parserBuilder().setSigningKey(secretKey).build().parseClaimsJws(jwtToken);
return !claims.getBody().getExpiration().before(new Date()); // 만료시간 이전이면 true 반환
} catch (Exception e) {
return false; // 만료시간 이후라면 false 반환
}
}

// request Header에서 토큰 값 가져오기
public String resolveToken(HttpServletRequest request) {
return request.getHeader("X-AUTH-TOKEN");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package balancetalk.module.member.application;

import balancetalk.global.exception.BalanceTalkException;
import balancetalk.global.exception.ErrorCode;
import balancetalk.global.jwt.JwtTokenProvider;
import balancetalk.module.member.domain.Member;
import balancetalk.module.member.domain.MemberRepository;
import balancetalk.module.member.dto.JoinDto;
import balancetalk.module.member.dto.LoginDto;
import balancetalk.module.member.dto.LoginSuccessDto;
import balancetalk.module.member.dto.MemberResponseDto;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
public class MemberService {

private final JwtTokenProvider jwtTokenProvider;
private final MemberRepository memberRepository;

@Transactional
public Long join(final JoinDto joinDto) {
Member member = joinDto.toEntity();
return memberRepository.save(member).getId();
}

@Transactional
public LoginSuccessDto login(final LoginDto loginDto) {
Member member = memberRepository.findByEmail(loginDto.getEmail())
.orElseThrow(() -> new BalanceTalkException(ErrorCode.MISMATCHED_EMAIL_OR_PASSWORD));
if (!member.getPassword().equals(loginDto.getPassword())) {
throw new BalanceTalkException(ErrorCode.MISMATCHED_EMAIL_OR_PASSWORD);
}
String token = jwtTokenProvider.createToken(member.getEmail(), member.getRole());

if (token == null) {
throw new BalanceTalkException(ErrorCode.AUTHENTICATION_ERROR);
}

return LoginSuccessDto.builder()
.email(member.getEmail())
.password(member.getPassword())
.role(member.getRole())
.token(token)
.build();
}

@Transactional(readOnly = true)
public MemberResponseDto findById(Long id) {
Member member = memberRepository.findById(id)
.orElseThrow(() -> new BalanceTalkException(ErrorCode.NOT_FOUND_MEMBER));
return MemberResponseDto.fromEntity(member);
}

@Transactional(readOnly = true)
public List<MemberResponseDto> findAll() {
List<Member> members = memberRepository.findAll();
return members.stream()
.map(MemberResponseDto::fromEntity)
.collect(Collectors.toList());
}
}
48 changes: 47 additions & 1 deletion src/main/java/balancetalk/module/member/domain/Member.java
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,21 @@
import jakarta.persistence.Id;
import jakarta.persistence.OneToMany;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Optional;

import jakarta.validation.constraints.*;
import lombok.*;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

@Entity
@Builder
@Getter
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Member extends BaseTimeEntity {
public class Member extends BaseTimeEntity implements UserDetails {

@Id
@GeneratedValue
Expand Down Expand Up @@ -82,6 +87,47 @@ public class Member extends BaseTimeEntity {
@OneToMany(mappedBy = "reporter")
private List<Report> reports = new ArrayList<>(); // 신고한 기록

@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return null;
}

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


@Override
public boolean isAccountNonExpired() {
return true;
}

@Override
public boolean isAccountNonLocked() {
return true;
}

@Override
public boolean isCredentialsNonExpired() {
return true;
}

@Override
public boolean isEnabled() {
return true;
}

public int getPostCount() {
return Optional.ofNullable(posts)
.map(List::size).orElse(0);
}

public int getPostLikes() {
return Optional.ofNullable(postLikes)
.map(List::size).orElse(0);
}

public boolean hasVoted(Post post) {
return votes.stream()
.anyMatch(vote -> vote.getBalanceOption().getPost().equals(post));
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package balancetalk.module.member.domain;

import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;
public interface MemberRepository extends JpaRepository<Member, Long> {
Optional<Member> findByEmail(String username);
}
30 changes: 30 additions & 0 deletions src/main/java/balancetalk/module/member/dto/JoinDto.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package balancetalk.module.member.dto;

import balancetalk.module.member.domain.Member;
import balancetalk.module.member.domain.Role;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class JoinDto {
private String nickname;
private String email;
private String password;
private Role role;
private String ip;
// TODO: profilePhoto 추가

public Member toEntity() {
return Member.builder()
.nickname(nickname)
.email(email)
.password(password)
.role(role)
.ip(ip)
.build();
}
}
Loading

0 comments on commit 95b6963

Please sign in to comment.