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
6 changes: 6 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,12 @@ 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'
}

tasks.named('test') {
Expand Down
25 changes: 20 additions & 5 deletions src/main/java/umc/config/SecurityConfig.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package umc.config;

import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
Expand All @@ -8,19 +9,29 @@
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.filter.JwtAuthFilter;
import umc.global.security.handler.CustomAccessDenied;
import umc.global.security.handler.CustomEntryPoint;
import umc.global.security.service.CustomUserDetailService;
import umc.global.security.util.JwtUtil;

@EnableWebSecurity
@Configuration
@RequiredArgsConstructor
public class SecurityConfig {

private final JwtUtil jwtUtil;
private final CustomUserDetailService customUserDetailService;

private final String[] allowUris = {
// Swagger 허용
"/swagger-ui/**",
"/swagger-resources/**",
"/v3/api-docs/**",
"/auth/**",
"/api/v1/auth/signup"
"/api/v1/auth/signup",
"/api/v1/login"
};

@Bean
Expand All @@ -31,10 +42,9 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
.requestMatchers(allowUris).permitAll()
.anyRequest().authenticated()
)
.formLogin(form -> form
.defaultSuccessUrl("/swagger-ui/index.html", true)
.permitAll()
)
.formLogin(AbstractHttpConfigurer::disable)
.sessionManagement(AbstractHttpConfigurer::disable)
.addFilterBefore(jwtAuthFilter(), UsernamePasswordAuthenticationFilter.class)
.logout(logout -> logout
.logoutUrl("/logout")
.logoutSuccessUrl("/login?logout")
Expand All @@ -61,4 +71,9 @@ public CustomAccessDenied customAccessDenied(){
public CustomEntryPoint customEntryPoint(){
return new CustomEntryPoint();
}

@Bean
public JwtAuthFilter jwtAuthFilter(){
return new JwtAuthFilter(jwtUtil, customUserDetailService);
}
}
73 changes: 73 additions & 0 deletions src/main/java/umc/global/security/filter/JwtAuthFilter.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package umc.global.security.filter;

import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.filter.OncePerRequestFilter;
import umc.global.apiPayload.ApiResponse;
import umc.global.apiPayload.code.BaseErrorCode;
import umc.global.apiPayload.code.GeneralErrorCode;
import umc.global.security.service.CustomUserDetailService;
import umc.global.security.util.JwtUtil;

import java.io.IOException;

@RequiredArgsConstructor
public class JwtAuthFilter extends OncePerRequestFilter {

private final JwtUtil jwtUtil;
private final CustomUserDetailService customUserDetailsService;

@Override
protected void doFilterInternal(
@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain
) throws ServletException, IOException {

try {
// 토큰 가져오기
String token = request.getHeader("Authorization");
// token이 없거나 Bearer가 아니면 넘기기
if (token == null || !token.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
// Bearer이면 추출
token = token.replace("Bearer ", "");
// AccessToken 검증하기: 올바른 토큰이면
if (jwtUtil.isValid(token)) {
// 토큰에서 이메일 추출
String email = jwtUtil.getEmail(token);
// 인증 객체 생성: 이메일로 찾아온 뒤, 인증 객체 생성
UserDetails user = customUserDetailsService.loadUserByUsername(email);
Authentication auth = new UsernamePasswordAuthenticationToken(
user,
null,
user.getAuthorities()
);
// 인증 완료 후 SecurityContextHolder에 넣기
SecurityContextHolder.getContext().setAuthentication(auth);
}
filterChain.doFilter(request, response);
} catch (Exception e) {
ObjectMapper mapper = new ObjectMapper();
BaseErrorCode code = GeneralErrorCode.UNAUTHORIZED;

response.setContentType("application/json;charset=UTF-8");
response.setStatus(code.getStatus().value());

ApiResponse<Void> errorResponse = ApiResponse.onFailure(code,null);

mapper.writeValue(response.getOutputStream(), errorResponse);
}
}
}
83 changes: 83 additions & 0 deletions src/main/java/umc/global/security/util/JwtUtil.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package umc.global.security.util;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.stereotype.Component;
import umc.global.security.entity.AuthMember;

import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
import java.util.Date;
import java.util.stream.Collectors;

@Component
public class JwtUtil {
private final SecretKey secretKey;
private final Duration accessExpiration;

public JwtUtil(
@Value("${jwt.token.secretKey}") String secret,
@Value("${jwt.token.expiration.access}") Long accessExpiration
) {
this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
this.accessExpiration = Duration.ofMillis(accessExpiration);
}

// AccessToken 생성
public String createAccessToken(AuthMember member) {
return createToken(member, accessExpiration);
}

public String getEmail(String token) {
try {
return getClaims(token).getPayload().getSubject(); // Parsing해서 Subject 가져오기
} catch (JwtException e) {
return null;
}
}

public boolean isValid(String token) {
try {
getClaims(token);
return true;
} catch (JwtException e) {
return false;
}
}

// 토큰 생성
private String createToken(AuthMember member, Duration expiration) {
Instant now = Instant.now();

// 인가 정보
String authorities = member.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));

return Jwts.builder()
.subject(member.getUsername()) // User 이메일을 Subject로
.claim("role", authorities)
.claim("email", member.getUsername())
.issuedAt(Date.from(now)) // 언제 발급한지
.expiration(Date.from(now.plus(expiration))) // 언제까지 유효한지
.signWith(secretKey) // sign할 Key
.compact();
}

// 토큰 정보 가져오기
private Jws<Claims> getClaims(String token) throws JwtException {
return Jwts.parser()
.verifyWith(secretKey)
.clockSkewSeconds(60)
.build()
.parseSignedClaims(token);
}
}
19 changes: 15 additions & 4 deletions src/main/java/umc/member/controller/MemberController.java
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
package umc.member.controller;

import io.swagger.v3.oas.annotations.Operation;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import umc.global.apiPayload.ApiResponse;
import umc.global.apiPayload.code.BaseSuccessCode;
import umc.global.security.entity.AuthMember;
import umc.member.dto.MemberReqDTO;
import umc.member.dto.MemberResDTO;
import umc.member.exception.code.MemberSuccessCode;
Expand All @@ -20,13 +23,13 @@ public class MemberController {
private final MemberService memberService;
private final MissionService missionService;

@PostMapping("/v1/users/me")
@GetMapping("/v2/users/me")
@Operation(summary = "마이페이지 조회")
public ApiResponse<MemberResDTO.GetInfo> getInfo(
@RequestBody MemberReqDTO.GetInfo dto
@AuthenticationPrincipal AuthMember member
){
BaseSuccessCode code = MemberSuccessCode.OK;
return ApiResponse.onSuccess(code, memberService.getInfo(dto));
return ApiResponse.onSuccess(code, memberService.getInfo(member));
}

@GetMapping("/v1/home")
Expand All @@ -42,10 +45,18 @@ public ApiResponse<MissionResDTO.MissionListDTO> getHomeInfo(
@PostMapping("/v1/auth/signup")
@Operation(summary = "회원가입")
public ApiResponse<MemberResDTO.AuthResDTO.SignUpResultDTO> signUp(
@RequestBody MemberReqDTO.SingUpDTO request
@Valid @RequestBody MemberReqDTO.SingUpDTO request
) {
MemberResDTO.AuthResDTO.SignUpResultDTO result = memberService.signUp(request);

return ApiResponse.onSuccess(MemberSuccessCode.JOIN_OK, result);
}

@PostMapping("/v1/login")
@Operation(summary = "로그인")
public ApiResponse<MemberResDTO.LoginResDTO> login(
@Valid @RequestBody MemberReqDTO.LoginDTO request
) {
return ApiResponse.onSuccess(MemberSuccessCode.LOGIN_OK, memberService.login(request));
}
}
8 changes: 4 additions & 4 deletions src/main/java/umc/member/converter/MemberConverter.java
Original file line number Diff line number Diff line change
Expand Up @@ -26,16 +26,16 @@ public static MemberResDTO.GetInfo toGetInfo(
.build();
}

public static Member toMember(MemberReqDTO.SingUpDTO request, String password){
public static Member toMember(MemberReqDTO.SingUpDTO request, String password, Gender gender, SocialType socialType){
return Member.builder()
.email(request.email())
.password(password)
.name(request.name())
.gender(Gender.valueOf(request.gender()))
.gender(gender)
.birth(request.birth())
.address(request.address())
.socialUid(request.socailUid())
.socialType(SocialType.valueOf(request.socailType()))
.socialUid(request.socialUid())
.socialType(socialType)
.point(0)
.build();
}
Expand Down
21 changes: 19 additions & 2 deletions src/main/java/umc/member/dto/MemberReqDTO.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package umc.member.dto;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import umc.member.enums.SocialType;

import java.time.LocalDate;
Expand All @@ -12,15 +14,30 @@ public record GetInfo(
){}

public record SingUpDTO(
@NotBlank(message = "이메일은 필수 입력 값입니다.")
@Email(message = "올바른 이메일 형식이 아닙니다.")
String email,
@NotBlank(message = "비밀번호는 필수 입력 값입니다.")
String password,
@NotBlank(message = "이름은 필수 입력 값입니다.")
String name,
@NotBlank(message = "성별은 필수 입력 값입니다.")
String gender,
@NotBlank(message = "생년월일은 필수 입력 값입니다.")
LocalDate birth,
@NotBlank(message = "주소는 필수 입력 값입니다.")
String address,
String socailUid,
String socailType,
String socialUid,
String socialType,
List<Long> agreedTerms,
List<Long> userFoods
) {}

public record LoginDTO(
@NotBlank(message = "이메일은 필수 입력 값입니다.")
@Email(message = "올바른 이메일 형식이 아닙니다.")
String email,
@NotBlank(message = "비밀번호는 필수 입력 값입니다.")
String password
) {}
}
7 changes: 7 additions & 0 deletions src/main/java/umc/member/dto/MemberResDTO.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package umc.member.dto;

import lombok.Builder;
import lombok.Getter;

import java.time.LocalDateTime;

Expand Down Expand Up @@ -28,4 +29,10 @@ public record SignUpResultDTO(
LocalDateTime createdAt
) {}
}

@Builder
@Getter
public static class LoginResDTO{
String accessToken;
}
}
2 changes: 1 addition & 1 deletion src/main/java/umc/member/entity/Member.java
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ public class Member extends BaseEntity {
@Column(name = "address", nullable = false)
private String address;

@Column(name = "email", nullable = false)
@Column(name = "email", nullable = false, unique = true)
private String email;

@Column(name = "password")
Expand Down
3 changes: 2 additions & 1 deletion src/main/java/umc/member/exception/code/MemberErrorCode.java
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ public enum MemberErrorCode implements BaseErrorCode {
MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "MEMBER404_1", "해당 사용자를 찾을 수 없습니다."),
DUPLICATE_EMAIL(HttpStatus.CONFLICT, "MEMBER409_1", "중복된 이메일입니다."),
INVALID_GENDER_TYPE(HttpStatus.BAD_REQUEST, "GENDER400_1", "유효하지 않은 성별입니다."),
INVALID_SOCIAL_TYPE(HttpStatus.BAD_REQUEST, "SOCIAL400_1", "유효하지 않은 소셜 타입입니다.");
INVALID_SOCIAL_TYPE(HttpStatus.BAD_REQUEST, "SOCIAL400_1", "유효하지 않은 소셜 타입입니다."),
INVALID_PASSWORD(HttpStatus.BAD_REQUEST, "MEMBER400_1", "일치하지 않는 비밀번호입니다.");

private final HttpStatus status;
private final String code;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ public enum MemberSuccessCode implements BaseSuccessCode {

OK(HttpStatus.OK, "MEMBER200_1", "성공적으로 유저를 조회했습니다."),

JOIN_OK(HttpStatus.OK, "MEMBER200_2", "성공적으로 가입을 완료했습니다.");
JOIN_OK(HttpStatus.OK, "MEMBER200_2", "성공적으로 가입을 완료했습니다."),
LOGIN_OK(HttpStatus.OK, "MEMBER200_3", "성공적으로 로그인했습니다."),;

private final HttpStatus status;
private final String code;
Expand Down
Loading