diff --git a/naru/umc10th/build.gradle b/naru/umc10th/build.gradle index 7ba1dfd..4ed2723 100644 --- a/naru/umc10th/build.gradle +++ b/naru/umc10th/build.gradle @@ -23,6 +23,9 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-webmvc' implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310' + implementation 'io.jsonwebtoken:jjwt-api:0.12.6' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6' runtimeOnly 'com.mysql:mysql-connector-j' compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' diff --git a/naru/umc10th/src/main/java/com/example/umc10th/domain/auth/controller/AuthController.java b/naru/umc10th/src/main/java/com/example/umc10th/domain/auth/controller/AuthController.java index 6fcdc73..85226a8 100644 --- a/naru/umc10th/src/main/java/com/example/umc10th/domain/auth/controller/AuthController.java +++ b/naru/umc10th/src/main/java/com/example/umc10th/domain/auth/controller/AuthController.java @@ -30,4 +30,13 @@ public ApiResponse signUp( AuthResponseDto.SignUpResultDto result = authService.signUp(request); return ApiResponse.onSuccess(UserSuccessCode.USER_JOIN_SUCCESS, result); } + + @Operation(summary = "로그인 API") + @PostMapping("/login") + public ApiResponse login( + @RequestBody @Valid AuthRequestDto.LoginDto request + ) { + AuthResponseDto.LoginResultDto result = authService.login(request); + return ApiResponse.onSuccess(UserSuccessCode.USER_LOGIN_SUCCESS, result); + } } diff --git a/naru/umc10th/src/main/java/com/example/umc10th/domain/auth/dto/AuthRequestDto.java b/naru/umc10th/src/main/java/com/example/umc10th/domain/auth/dto/AuthRequestDto.java index 1878961..76e11f7 100644 --- a/naru/umc10th/src/main/java/com/example/umc10th/domain/auth/dto/AuthRequestDto.java +++ b/naru/umc10th/src/main/java/com/example/umc10th/domain/auth/dto/AuthRequestDto.java @@ -13,6 +13,15 @@ public class AuthRequestDto { + public record LoginDto( + @NotBlank(message = "이메일은 필수입니다.") + @Email(message = "이메일 형식이 올바르지 않습니다.") + String email, + + @NotBlank(message = "비밀번호는 필수입니다.") + String password + ) {} + public record SignUpDto( @NotBlank(message = "이메일은 필수입니다.") @Email(message = "이메일 형식이 올바르지 않습니다.") diff --git a/naru/umc10th/src/main/java/com/example/umc10th/domain/auth/dto/AuthResponseDto.java b/naru/umc10th/src/main/java/com/example/umc10th/domain/auth/dto/AuthResponseDto.java index d341f89..7446eb2 100644 --- a/naru/umc10th/src/main/java/com/example/umc10th/domain/auth/dto/AuthResponseDto.java +++ b/naru/umc10th/src/main/java/com/example/umc10th/domain/auth/dto/AuthResponseDto.java @@ -10,4 +10,12 @@ public record SignUpResultDto( String email, String name ) {} + + @Builder + public record LoginResultDto( + String grantType, + String accessToken, + String refreshToken, + Long accessTokenExpiresIn + ) {} } diff --git a/naru/umc10th/src/main/java/com/example/umc10th/domain/auth/service/AuthService.java b/naru/umc10th/src/main/java/com/example/umc10th/domain/auth/service/AuthService.java index caf63ba..288710b 100644 --- a/naru/umc10th/src/main/java/com/example/umc10th/domain/auth/service/AuthService.java +++ b/naru/umc10th/src/main/java/com/example/umc10th/domain/auth/service/AuthService.java @@ -6,4 +6,6 @@ public interface AuthService { AuthResponseDto.SignUpResultDto signUp(AuthRequestDto.SignUpDto request); + + AuthResponseDto.LoginResultDto login(AuthRequestDto.LoginDto request); } diff --git a/naru/umc10th/src/main/java/com/example/umc10th/domain/auth/service/AuthServiceImpl.java b/naru/umc10th/src/main/java/com/example/umc10th/domain/auth/service/AuthServiceImpl.java index bae9aa3..00ec1ff 100644 --- a/naru/umc10th/src/main/java/com/example/umc10th/domain/auth/service/AuthServiceImpl.java +++ b/naru/umc10th/src/main/java/com/example/umc10th/domain/auth/service/AuthServiceImpl.java @@ -17,8 +17,13 @@ import com.example.umc10th.domain.user.exception.UserException; import com.example.umc10th.domain.user.exception.code.UserErrorCode; import com.example.umc10th.domain.user.repository.UserRepository; +import com.example.umc10th.global.security.jwt.JwtUtil; import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; @@ -37,6 +42,8 @@ public class AuthServiceImpl implements AuthService { private final TermRepository termRepository; private final UserAgreementRepository userAgreementRepository; private final PasswordEncoder passwordEncoder; + private final AuthenticationManager authenticationManager; + private final JwtUtil jwtUtil; @Override @Transactional @@ -71,6 +78,20 @@ public AuthResponseDto.SignUpResultDto signUp(AuthRequestDto.SignUpDto request) return AuthConverter.toSignUpResultDto(savedUser); } + @Override + public AuthResponseDto.LoginResultDto login(AuthRequestDto.LoginDto request) { + Authentication authentication = authenticationManager.authenticate( + new UsernamePasswordAuthenticationToken(request.email(), request.password()) + ); + UserDetails userDetails = (UserDetails) authentication.getPrincipal(); + return AuthResponseDto.LoginResultDto.builder() + .grantType("Bearer") + .accessToken(jwtUtil.createAccessToken(userDetails)) + .refreshToken(jwtUtil.createRefreshToken(userDetails)) + .accessTokenExpiresIn(jwtUtil.getAccessTokenExpiration()) + .build(); + } + private void validateRequiredTerms(Map agreementMap) { List requiredTerms = termRepository.findAllByIsRequiredTrue(); diff --git a/naru/umc10th/src/main/java/com/example/umc10th/domain/user/controller/UserController.java b/naru/umc10th/src/main/java/com/example/umc10th/domain/user/controller/UserController.java index 051c5bf..a0e7422 100644 --- a/naru/umc10th/src/main/java/com/example/umc10th/domain/user/controller/UserController.java +++ b/naru/umc10th/src/main/java/com/example/umc10th/domain/user/controller/UserController.java @@ -8,10 +8,12 @@ import com.example.umc10th.global.apiPayload.ApiResponse; import com.example.umc10th.global.apiPayload.code.BaseSuccessCode; import com.example.umc10th.global.apiPayload.code.GeneralSuccessCode; +import com.example.umc10th.global.security.CustomUserDetails; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; @RestController @@ -26,9 +28,9 @@ public class UserController { @Operation(summary = "마이페이지 조회 API") @GetMapping("/mypage") public ApiResponse getMyPage( - @RequestParam Long userId + @AuthenticationPrincipal CustomUserDetails userDetails ) { - UserResponseDto.MyPageResultDto result = userService.getMyPage(userId); + UserResponseDto.MyPageResultDto result = userService.getMyPage(userDetails.getUserId()); return ApiResponse.onSuccess(GeneralSuccessCode.OK, result); } diff --git a/naru/umc10th/src/main/java/com/example/umc10th/global/config/SecurityConfig.java b/naru/umc10th/src/main/java/com/example/umc10th/global/config/SecurityConfig.java index cf48e4d..6c00a54 100644 --- a/naru/umc10th/src/main/java/com/example/umc10th/global/config/SecurityConfig.java +++ b/naru/umc10th/src/main/java/com/example/umc10th/global/config/SecurityConfig.java @@ -4,14 +4,18 @@ import com.example.umc10th.global.security.handler.CustomAuthenticationEntryPoint; import com.example.umc10th.global.security.handler.CustomAuthenticationFailureHandler; import com.example.umc10th.global.security.handler.CustomAuthenticationSuccessHandler; +import com.example.umc10th.global.security.jwt.JwtAuthFilter; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; 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; @Configuration @RequiredArgsConstructor @@ -21,6 +25,7 @@ public class SecurityConfig { private final CustomAccessDeniedHandler accessDeniedHandler; private final CustomAuthenticationSuccessHandler authenticationSuccessHandler; private final CustomAuthenticationFailureHandler authenticationFailureHandler; + private final JwtAuthFilter jwtAuthFilter; private static final String[] PUBLIC_URLS = { "/auth/**", @@ -53,6 +58,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .authenticationEntryPoint(authenticationEntryPoint) .accessDeniedHandler(accessDeniedHandler) ) + .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class) .build(); } @@ -60,4 +66,9 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } + + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception { + return authenticationConfiguration.getAuthenticationManager(); + } } diff --git a/naru/umc10th/src/main/java/com/example/umc10th/global/security/handler/CustomAuthenticationSuccessHandler.java b/naru/umc10th/src/main/java/com/example/umc10th/global/security/handler/CustomAuthenticationSuccessHandler.java index 9314d69..608c979 100644 --- a/naru/umc10th/src/main/java/com/example/umc10th/global/security/handler/CustomAuthenticationSuccessHandler.java +++ b/naru/umc10th/src/main/java/com/example/umc10th/global/security/handler/CustomAuthenticationSuccessHandler.java @@ -1,10 +1,13 @@ package com.example.umc10th.global.security.handler; +import com.example.umc10th.domain.auth.dto.AuthResponseDto; import com.example.umc10th.domain.user.exception.code.UserSuccessCode; +import com.example.umc10th.global.security.jwt.JwtUtil; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import org.springframework.security.core.Authentication; +import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.web.authentication.AuthenticationSuccessHandler; import org.springframework.stereotype.Component; @@ -15,10 +18,18 @@ public class CustomAuthenticationSuccessHandler implements AuthenticationSuccessHandler { private final SecurityResponseWriter responseWriter; + private final JwtUtil jwtUtil; @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException { - responseWriter.writeSuccess(response, UserSuccessCode.USER_LOGIN_SUCCESS); + UserDetails userDetails = (UserDetails) authentication.getPrincipal(); + AuthResponseDto.LoginResultDto result = AuthResponseDto.LoginResultDto.builder() + .grantType("Bearer") + .accessToken(jwtUtil.createAccessToken(userDetails)) + .refreshToken(jwtUtil.createRefreshToken(userDetails)) + .accessTokenExpiresIn(jwtUtil.getAccessTokenExpiration()) + .build(); + responseWriter.writeSuccess(response, UserSuccessCode.USER_LOGIN_SUCCESS, result); } } diff --git a/naru/umc10th/src/main/java/com/example/umc10th/global/security/handler/SecurityResponseWriter.java b/naru/umc10th/src/main/java/com/example/umc10th/global/security/handler/SecurityResponseWriter.java index 30f65ce..e7915a2 100644 --- a/naru/umc10th/src/main/java/com/example/umc10th/global/security/handler/SecurityResponseWriter.java +++ b/naru/umc10th/src/main/java/com/example/umc10th/global/security/handler/SecurityResponseWriter.java @@ -25,10 +25,10 @@ public void writeFailure(HttpServletResponse response, BaseErrorCode code) throw objectMapper.writeValue(response.getWriter(), ApiResponse.onFailure(code, null)); } - public void writeSuccess(HttpServletResponse response, BaseSuccessCode code) throws IOException { + public void writeSuccess(HttpServletResponse response, BaseSuccessCode code, T result) throws IOException { response.setStatus(code.getStatus().value()); response.setContentType(MediaType.APPLICATION_JSON_VALUE); response.setCharacterEncoding(StandardCharsets.UTF_8.name()); - objectMapper.writeValue(response.getWriter(), ApiResponse.onSuccess(code, null)); + objectMapper.writeValue(response.getWriter(), ApiResponse.onSuccess(code, result)); } } diff --git a/naru/umc10th/src/main/java/com/example/umc10th/global/security/jwt/JwtAuthFilter.java b/naru/umc10th/src/main/java/com/example/umc10th/global/security/jwt/JwtAuthFilter.java new file mode 100644 index 0000000..d18f143 --- /dev/null +++ b/naru/umc10th/src/main/java/com/example/umc10th/global/security/jwt/JwtAuthFilter.java @@ -0,0 +1,66 @@ +package com.example.umc10th.global.security.jwt; + +import com.example.umc10th.global.apiPayload.code.GeneralErrorCode; +import com.example.umc10th.global.security.handler.SecurityResponseWriter; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Component +@RequiredArgsConstructor +public class JwtAuthFilter extends OncePerRequestFilter { + + private static final String AUTHORIZATION_HEADER = "Authorization"; + private static final String BEARER_PREFIX = "Bearer "; + + private final JwtUtil jwtUtil; + private final UserDetailsService userDetailsService; + private final SecurityResponseWriter responseWriter; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + String authorizationHeader = request.getHeader(AUTHORIZATION_HEADER); + + if (authorizationHeader == null || !authorizationHeader.startsWith(BEARER_PREFIX)) { + filterChain.doFilter(request, response); + return; + } + + String token = authorizationHeader.substring(BEARER_PREFIX.length()); + + if (!jwtUtil.validateToken(token)) { + responseWriter.writeFailure(response, GeneralErrorCode.UNAUTHORIZED); + return; + } + + try { + String email = jwtUtil.getEmail(token); + + if (SecurityContextHolder.getContext().getAuthentication() == null) { + UserDetails userDetails = userDetailsService.loadUserByUsername(email); + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); + authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + + filterChain.doFilter(request, response); + } catch (UsernameNotFoundException | IllegalArgumentException e) { + SecurityContextHolder.clearContext(); + responseWriter.writeFailure(response, GeneralErrorCode.UNAUTHORIZED); + } + } +} diff --git a/naru/umc10th/src/main/java/com/example/umc10th/global/security/jwt/JwtUtil.java b/naru/umc10th/src/main/java/com/example/umc10th/global/security/jwt/JwtUtil.java new file mode 100644 index 0000000..95ac170 --- /dev/null +++ b/naru/umc10th/src/main/java/com/example/umc10th/global/security/jwt/JwtUtil.java @@ -0,0 +1,91 @@ +package com.example.umc10th.global.security.jwt; + +import io.jsonwebtoken.Claims; +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.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.util.Date; +import java.util.List; + +@Component +public class JwtUtil { + + private final SecretKey secretKey; + private final long accessTokenExpiration; + private final long refreshTokenExpiration; + + public JwtUtil( + @Value("${jwt.token.secretKey}") String secretKey, + @Value("${jwt.token.expiration.access}") long accessTokenExpiration, + @Value("${jwt.token.expiration.refresh}") long refreshTokenExpiration + ) { + this.secretKey = Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8)); + this.accessTokenExpiration = accessTokenExpiration; + this.refreshTokenExpiration = refreshTokenExpiration; + } + + public String createAccessToken(UserDetails userDetails) { + Date now = new Date(); + Date expiration = new Date(now.getTime() + accessTokenExpiration); + List authorities = userDetails.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .toList(); + String role = authorities.stream() + .findFirst() + .map(authority -> authority.replaceFirst("^ROLE_", "")) + .orElse(""); + + return Jwts.builder() + .subject(userDetails.getUsername()) + .claim("role", role) + .claim("authorities", authorities) + .issuedAt(now) + .expiration(expiration) + .signWith(secretKey) + .compact(); + } + + public String createRefreshToken(UserDetails userDetails) { + Date now = new Date(); + Date expiration = new Date(now.getTime() + refreshTokenExpiration); + + return Jwts.builder() + .subject(userDetails.getUsername()) + .issuedAt(now) + .expiration(expiration) + .signWith(secretKey) + .compact(); + } + + public long getAccessTokenExpiration() { + return accessTokenExpiration; + } + + public String getEmail(String token) { + return parseClaims(token).getSubject(); + } + + public boolean validateToken(String token) { + try { + parseClaims(token); + return true; + } catch (JwtException | IllegalArgumentException e) { + return false; + } + } + + private Claims parseClaims(String token) { + return Jwts.parser() + .verifyWith(secretKey) + .build() + .parseSignedClaims(token) + .getPayload(); + } +} diff --git a/naru/umc10th/src/main/resources/application.yml b/naru/umc10th/src/main/resources/application.yml index c9789cc..7046898 100644 --- a/naru/umc10th/src/main/resources/application.yml +++ b/naru/umc10th/src/main/resources/application.yml @@ -16,4 +16,11 @@ spring: ddl-auto: update properties: hibernate: - format_sql: true \ No newline at end of file + format_sql: true + +jwt: + token: + secretKey: ${JWT_SECRET_KEY} + expiration: + access: 3600000 + refresh: 1209600000