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
7 changes: 7 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,11 @@ repositories {
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-webmvc'
implementation 'org.springframework.boot:spring-boot-starter-security'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'com.mysql:mysql-connector-j'
annotationProcessor 'org.projectlombok:lombok'
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa-test'
testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test'
testCompileOnly 'org.projectlombok:lombok'
Expand All @@ -38,6 +40,11 @@ dependencies {
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.1'
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-api:3.0.1'

// 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'

}

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

import com.umc.umc10th.domain.user.apipayload.code.UserSuccessCode;
import com.umc.umc10th.domain.user.dto.request.UserRequestDto;
import com.umc.umc10th.domain.user.dto.response.UserResponseDto;
import com.umc.umc10th.domain.user.service.UserService;
import com.umc.umc10th.global.apipayload.ApiResponse;
import com.umc.umc10th.global.apipayload.code.BaseSuccessCode;
Expand All @@ -22,4 +23,9 @@ public ApiResponse<Void> signup(@Valid @RequestBody UserRequestDto.CreateUser dt
userService.createUser(dto);
return ApiResponse.onSuccess(code);
}

@PostMapping("/login")
public ApiResponse<UserResponseDto.Login> login(@Valid @RequestBody UserRequestDto.Login dto) {
return ApiResponse.onSuccess(UserSuccessCode.OK, userService.login(dto));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@
import com.umc.umc10th.domain.user.service.UserService;
import com.umc.umc10th.global.apipayload.ApiResponse;
import com.umc.umc10th.global.apipayload.code.BaseSuccessCode;
import com.umc.umc10th.global.security.AuthUser;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;

@RestController
Expand All @@ -20,9 +22,8 @@ public class UserController {
private final UserService userService;

@GetMapping("/me")
public ApiResponse<UserResponseDto.GetInfo> getInfo() {
BaseSuccessCode code = UserSuccessCode.OK;
return ApiResponse.onSuccess(code, userService.getInfo());
public ApiResponse<UserResponseDto.GetInfo> getInfo(@AuthenticationPrincipal AuthUser authUser) {
return ApiResponse.onSuccess(UserSuccessCode.OK, userService.getInfo(authUser));
}

@PostMapping("/me/missions")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,15 @@ public record CreateUser(
String phone
) {}

@Builder
public record Login(
@NotBlank(message = "이메일은 필수입니다.")
@Email(message = "올바른 이메일 형식이 아닙니다.")
String email,
@NotBlank(message = "비밀번호는 필수입니다.")
String password
) {}

@Builder
public record GetMissionsRequest(
@NotNull(message = "사용자 ID는 필수입니다")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
import lombok.Builder;

public class UserResponseDto {
public record Login(String token) {}

@Builder
public record GetInfo(
String nickname,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
public enum UserErrorCode implements BaseErrorCode {
DUPLICATE_EMAIL(HttpStatus.CONFLICT, "USER409_1", "이미 사용 중인 이메일입니다."),
USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER404_1", "존재하지 않는 유저입니다."),
INVALID_PASSWORD(HttpStatus.UNAUTHORIZED, "USER401_1", "이메일 또는 비밀번호가 올바르지 않습니다."),
;

private final HttpStatus httpStatus;
Expand Down
27 changes: 18 additions & 9 deletions src/main/java/com/umc/umc10th/domain/user/service/UserService.java
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
import com.umc.umc10th.domain.user.exception.code.UserErrorCode;
import com.umc.umc10th.domain.user.repository.UserDoingMissionRepository;
import com.umc.umc10th.domain.user.repository.UserRepository;
import com.umc.umc10th.global.security.AuthUser;
import com.umc.umc10th.global.security.util.JwtUtil;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
Expand All @@ -34,9 +36,8 @@ public class UserService {
private final UserDoingMissionRepository userDoingMissionRepository;
private final ReviewRepository reviewRepository;
private final PasswordEncoder passwordEncoder;
private final JwtUtil jwtUtil;

// 임시 인증 유저 ID - 실제로는 SecurityContextHolder 등에서 추출
private static final Long TEMP_USER_ID = 1L;
private static final int PAGE_SIZE = 10;

@Transactional
Expand All @@ -48,10 +49,18 @@ public void createUser(UserRequestDto.CreateUser dto) {
userRepository.save(UserConverter.toUser(dto, encodedPassword));
}

public UserResponseDto.GetInfo getInfo() {
User user = userRepository.findUserInfo(TEMP_USER_ID)
.orElseThrow(() -> new IllegalArgumentException("존재하지 않는 유저입니다."));
public UserResponseDto.Login login(UserRequestDto.Login dto) {
User user = userRepository.findByLoginId(dto.email())
.orElseThrow(() -> new UserException(UserErrorCode.INVALID_PASSWORD));
if (!passwordEncoder.matches(dto.password(), user.getPassword())) {
throw new UserException(UserErrorCode.INVALID_PASSWORD);
}
String token = jwtUtil.createAccessToken(new AuthUser(user));
return new UserResponseDto.Login(token);
}

public UserResponseDto.GetInfo getInfo(AuthUser authUser) {
User user = authUser.getUser();
return UserResponseDto.GetInfo.builder()
.nickname(user.getName())
.email(user.getLoginId())
Expand All @@ -60,9 +69,9 @@ public UserResponseDto.GetInfo getInfo() {
.build();
}

public MissionResponseDto.GetMissions getMissions(Long locationId, String status) {
public MissionResponseDto.GetMissions getMissions(Long locationId, String status, AuthUser authUser) {
Pageable pageable = PageRequest.of(0, PAGE_SIZE);
Page<UserDoingMission> page = userDoingMissionRepository.findMyMissions(TEMP_USER_ID, status, pageable);
Page<UserDoingMission> page = userDoingMissionRepository.findMyMissions(authUser.getUser().getId(), status, pageable);

List<MissionResponseDto.GetMissions.GetMission> items = page.getContent().stream()
.map(udm -> MissionResponseDto.GetMissions.GetMission.builder()
Expand All @@ -77,9 +86,9 @@ public MissionResponseDto.GetMissions getMissions(Long locationId, String status
.build();
}

public ReviewResponseDto.GetMyReviews getMyReviews() {
public ReviewResponseDto.GetMyReviews getMyReviews(AuthUser authUser) {
Pageable pageable = PageRequest.of(0, PAGE_SIZE);
Page<Review> page = reviewRepository.findMyReviews(TEMP_USER_ID, pageable);
Page<Review> page = reviewRepository.findMyReviews(authUser.getUser().getId(), pageable);

List<ReviewResponseDto.GetMyReviews.ReviewItem> items = page.getContent().stream()
.map(r -> ReviewResponseDto.GetMyReviews.ReviewItem.builder()
Expand Down
24 changes: 15 additions & 9 deletions src/main/java/com/umc/umc10th/global/config/SecurityConfig.java
Original file line number Diff line number Diff line change
@@ -1,16 +1,21 @@
package com.umc.umc10th.global.config;

import com.umc.umc10th.global.security.CustomUserDetailsService;
import com.umc.umc10th.global.security.filter.JwtAuthFilter;
import com.umc.umc10th.global.security.handler.CustomAccessDenied;
import com.umc.umc10th.global.security.handler.CustomEntryPoint;
import com.umc.umc10th.global.security.util.JwtUtil;
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;

@EnableWebSecurity
@Configuration
Expand All @@ -19,6 +24,8 @@ public class SecurityConfig {

private final CustomAccessDenied customAccessDenied;
private final CustomEntryPoint customEntryPoint;
private final JwtUtil jwtUtil;
private final CustomUserDetailsService customUserDetailsService;

private final String[] allowUris = {
"/swagger-ui/**",
Expand All @@ -27,23 +34,22 @@ public class SecurityConfig {
"/auth/**"
};

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

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(requests -> requests
.requestMatchers(allowUris).permitAll()
.anyRequest().authenticated()
)
.formLogin(form -> form
.defaultSuccessUrl("/swagger-ui/index.html", true)
.permitAll()
)
.logout(logout -> logout
.logoutUrl("/logout")
.logoutSuccessUrl("/login?logout")
.permitAll()
)
.addFilterBefore(jwtAuthFilter(), UsernamePasswordAuthenticationFilter.class)
.exceptionHandling(e -> e
.accessDeniedHandler(customAccessDenied)
.authenticationEntryPoint(customEntryPoint)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package com.umc.umc10th.global.security.filter;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.umc.umc10th.global.apipayload.ApiResponse;
import com.umc.umc10th.global.apipayload.code.BaseErrorCode;
import com.umc.umc10th.global.apipayload.code.GeneralErrorCode;
import com.umc.umc10th.global.security.CustomUserDetailsService;
import com.umc.umc10th.global.security.util.JwtUtil;
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 java.io.IOException;

@RequiredArgsConstructor
public class JwtAuthFilter extends OncePerRequestFilter {

private final JwtUtil jwtUtil;
private final CustomUserDetailsService customUserDetailsService;

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

try {
String token = request.getHeader("Authorization");

if (token == null || !token.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}

token = token.replace("Bearer ", "");

if (jwtUtil.isValid(token)) {
String email = jwtUtil.getEmail(token);
UserDetails user = customUserDetailsService.loadUserByUsername(email);
Authentication auth = new UsernamePasswordAuthenticationToken(
user,
null,
user.getAuthorities()
);
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);
}
}
}
79 changes: 79 additions & 0 deletions src/main/java/com/umc/umc10th/global/security/util/JwtUtil.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package com.umc.umc10th.global.security.util;

import com.umc.umc10th.global.security.AuthUser;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.Jws;
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 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);
}

public String createAccessToken(AuthUser member) {
return createToken(member, accessExpiration);
}

public String getEmail(String token) {
try {
return getClaims(token).getPayload().getSubject();
} catch (JwtException e) {
return null;
}
}

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

private String createToken(AuthUser member, Duration expiration) {
Instant now = Instant.now();

String authorities = member.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));

return Jwts.builder()
.subject(member.getUsername())
.claim("role", authorities)
.claim("email", member.getUsername())
.issuedAt(Date.from(now))
.expiration(Date.from(now.plus(expiration)))
.signWith(secretKey)
.compact();
}

private Jws<Claims> getClaims(String token) throws JwtException {
return Jwts.parser()
.verifyWith(secretKey)
.clockSkewSeconds(60)
.build()
.parseSignedClaims(token);
}
}
8 changes: 7 additions & 1 deletion src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,10 @@ spring:
ddl-auto: update # ?????? ?? ? ?????? ???? ??? ??
properties:
hibernate:
format_sql: true # ???? SQL ??? ?? ?? ???
format_sql: true # ???? SQL ??? ?? ?? ???

jwt:
token:
secretKey: ${JWT_SECRET_KEY}
expiration:
access: 1800000 # 30분