diff --git a/build.gradle b/build.gradle
index 9cab917..3cea095 100644
--- a/build.gradle
+++ b/build.gradle
@@ -56,6 +56,11 @@ dependencies {
// Spring Configuration Processor
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
+ // JWT
+ implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
+ implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'
+ implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'
+
// AWS S3
implementation 'com.amazonaws:aws-java-sdk-s3:1.12.658'
@@ -126,8 +131,8 @@ jacocoTestCoverageVerification {
}
includes = [
- '*.*Service*',
- '*.*Controller*'
+ '*service.*Service*',
+ '*controller.*Controller*'
]
}
}
diff --git a/src/main/java/com/ajou/hertz/common/auth/CustomUserDetailsService.java b/src/main/java/com/ajou/hertz/common/auth/CustomUserDetailsService.java
new file mode 100644
index 0000000..6ed520a
--- /dev/null
+++ b/src/main/java/com/ajou/hertz/common/auth/CustomUserDetailsService.java
@@ -0,0 +1,18 @@
+package com.ajou.hertz.common.auth;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.security.core.userdetails.UserDetailsService;
+
+import com.ajou.hertz.domain.user.service.UserQueryService;
+
+@Configuration
+public class CustomUserDetailsService {
+
+ @Bean
+ public UserDetailsService userDetailsService(UserQueryService userQueryService) {
+ return username -> new UserPrincipal(
+ userQueryService.getDtoById(Long.parseLong(username))
+ );
+ }
+}
diff --git a/src/main/java/com/ajou/hertz/common/auth/JwtAccessDeniedHandler.java b/src/main/java/com/ajou/hertz/common/auth/JwtAccessDeniedHandler.java
new file mode 100644
index 0000000..12fd8fa
--- /dev/null
+++ b/src/main/java/com/ajou/hertz/common/auth/JwtAccessDeniedHandler.java
@@ -0,0 +1,55 @@
+package com.ajou.hertz.common.auth;
+
+import static org.springframework.http.HttpStatus.*;
+import static org.springframework.http.MediaType.*;
+
+import java.io.IOException;
+
+import org.springframework.security.access.AccessDeniedException;
+import org.springframework.security.web.access.AccessDeniedHandler;
+import org.springframework.stereotype.Component;
+
+import com.ajou.hertz.common.exception.constant.CustomExceptionType;
+import com.ajou.hertz.common.exception.dto.response.ErrorResponse;
+import com.ajou.hertz.common.exception.util.ExceptionUtils;
+import com.ajou.hertz.common.logger.Logger;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+
+@Component
+public class JwtAccessDeniedHandler implements AccessDeniedHandler {
+
+ /**
+ * Endpoint에 대해 접근 권한이 존재하지 않을 때 동작하는 handler.
+ *
+ * @param request that resulted in an AccessDeniedException
+ * @param response so that the user agent can be advised of the failure
+ * @param accessDeniedException that caused the invocation
+ * @throws IOException if an input or output exception occurred
+ */
+ @Override
+ public void handle(
+ HttpServletRequest request,
+ HttpServletResponse response,
+ AccessDeniedException accessDeniedException
+ ) throws IOException {
+ Logger.warn(String.format(
+ "JwtAccessDeniedHandler.handle() ex=%s",
+ ExceptionUtils.getExceptionStackTrace(accessDeniedException)
+ ));
+
+ response.setStatus(FORBIDDEN.value());
+ response.setContentType(APPLICATION_JSON_VALUE);
+ response.setCharacterEncoding("utf-8");
+ response.getWriter().write(
+ new ObjectMapper().writeValueAsString(
+ new ErrorResponse(
+ CustomExceptionType.ACCESS_DENIED.getCode(),
+ CustomExceptionType.ACCESS_DENIED.getMessage()
+ )
+ )
+ );
+ }
+}
diff --git a/src/main/java/com/ajou/hertz/common/auth/JwtAuthenticationEntryPoint.java b/src/main/java/com/ajou/hertz/common/auth/JwtAuthenticationEntryPoint.java
new file mode 100644
index 0000000..aca807c
--- /dev/null
+++ b/src/main/java/com/ajou/hertz/common/auth/JwtAuthenticationEntryPoint.java
@@ -0,0 +1,55 @@
+package com.ajou.hertz.common.auth;
+
+import static org.springframework.http.HttpStatus.*;
+import static org.springframework.http.MediaType.*;
+
+import java.io.IOException;
+
+import org.springframework.security.core.AuthenticationException;
+import org.springframework.security.web.AuthenticationEntryPoint;
+import org.springframework.stereotype.Component;
+
+import com.ajou.hertz.common.exception.constant.CustomExceptionType;
+import com.ajou.hertz.common.exception.dto.response.ErrorResponse;
+import com.ajou.hertz.common.exception.util.ExceptionUtils;
+import com.ajou.hertz.common.logger.Logger;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+
+@Component
+public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
+
+ /**
+ * 인증이 필요한 endpoint에 대해 인증되지 않았을 때 동작하는 handler.
+ *
+ * @param request that resulted in an AuthenticationException
+ * @param response so that the user agent can begin authentication
+ * @param authenticationException that caused the invocation
+ * @throws IOException if an input or output exception occurred
+ */
+ @Override
+ public void commence(
+ HttpServletRequest request,
+ HttpServletResponse response,
+ AuthenticationException authenticationException
+ ) throws IOException {
+ Logger.warn(String.format(
+ "JwtAuthenticationEntryPoint.commence() ex=%s",
+ ExceptionUtils.getExceptionStackTrace(authenticationException)
+ ));
+
+ response.setStatus(UNAUTHORIZED.value());
+ response.setContentType(APPLICATION_JSON_VALUE);
+ response.setCharacterEncoding("utf-8");
+ response.getWriter().write(
+ new ObjectMapper().writeValueAsString(
+ new ErrorResponse(
+ CustomExceptionType.ACCESS_DENIED.getCode(),
+ CustomExceptionType.ACCESS_DENIED.getMessage()
+ )
+ )
+ );
+ }
+}
diff --git a/src/main/java/com/ajou/hertz/common/auth/JwtAuthenticationFilter.java b/src/main/java/com/ajou/hertz/common/auth/JwtAuthenticationFilter.java
new file mode 100644
index 0000000..bef93d2
--- /dev/null
+++ b/src/main/java/com/ajou/hertz/common/auth/JwtAuthenticationFilter.java
@@ -0,0 +1,67 @@
+package com.ajou.hertz.common.auth;
+
+import java.io.IOException;
+
+import org.springframework.http.HttpHeaders;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.stereotype.Component;
+import org.springframework.util.StringUtils;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.RequiredArgsConstructor;
+
+@RequiredArgsConstructor
+@Component
+public class JwtAuthenticationFilter extends OncePerRequestFilter {
+
+ private static final String TOKEN_TYPE_BEARER_PREFIX = "Bearer ";
+
+ private final JwtTokenProvider jwtTokenProvider;
+
+ /**
+ * 모든 요청마다 작동하여, jwt access token을 확인한다.
+ * 유효한 token이 있는 경우 token을 parsing해서 사용자 정보를 읽고 SecurityContext에 사용자 정보를 저장한다.
+ *
+ * @param request request 객체
+ * @param response response 객체
+ * @param filterChain FilterChain 객체
+ */
+ @Override
+ protected void doFilterInternal(
+ HttpServletRequest request,
+ HttpServletResponse response,
+ FilterChain filterChain
+ ) throws ServletException, IOException {
+ String accessToken = getAccessToken(request);
+
+ if (StringUtils.hasText(accessToken)) {
+ try {
+ jwtTokenProvider.validateToken(accessToken);
+ Authentication authentication = jwtTokenProvider.getAuthentication(accessToken);
+ SecurityContextHolder.getContext().setAuthentication(authentication);
+ } catch (Exception ignored) {
+ // 인증 권한 설정 중 에러가 발생하면 권한을 부여하지 않고 다음 단계로 진행
+ }
+ }
+ filterChain.doFilter(request, response);
+ }
+
+ /**
+ * Request의 header에서 token을 읽어온다.
+ *
+ * @param request Request 객체
+ * @return Header에서 추출한 token
+ */
+ public String getAccessToken(HttpServletRequest request) {
+ String authorizationHeader = request.getHeader(HttpHeaders.AUTHORIZATION);
+ if (authorizationHeader == null || !authorizationHeader.startsWith(TOKEN_TYPE_BEARER_PREFIX)) {
+ return null;
+ }
+ return authorizationHeader.substring(TOKEN_TYPE_BEARER_PREFIX.length());
+ }
+}
diff --git a/src/main/java/com/ajou/hertz/common/auth/JwtExceptionFilter.java b/src/main/java/com/ajou/hertz/common/auth/JwtExceptionFilter.java
new file mode 100644
index 0000000..e039431
--- /dev/null
+++ b/src/main/java/com/ajou/hertz/common/auth/JwtExceptionFilter.java
@@ -0,0 +1,52 @@
+package com.ajou.hertz.common.auth;
+
+import java.io.IOException;
+
+import org.springframework.stereotype.Component;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+import com.ajou.hertz.common.auth.exception.TokenValidateException;
+import com.ajou.hertz.common.exception.constant.CustomExceptionType;
+import com.ajou.hertz.common.exception.dto.response.ErrorResponse;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+
+/**
+ * JwtAuthenticationFilter
에서 발생하는 에러를 처리하기 위한 filter
+ *
+ * @see JwtAuthenticationFilter
+ */
+@Component
+public class JwtExceptionFilter extends OncePerRequestFilter {
+
+ @Override
+ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
+ FilterChain filterChain) throws ServletException, IOException {
+ try {
+ filterChain.doFilter(request, response);
+ } catch (TokenValidateException ex) {
+ setErrorResponse(CustomExceptionType.TOKEN_VALIDATE, response);
+ }
+ }
+
+ /**
+ * Exception 정보를 입력받아 응답할 error response를 설정한다.
+ *
+ * @param exceptionType exception type
+ * @param response HttpServletResponse 객체
+ */
+ private void setErrorResponse(
+ CustomExceptionType exceptionType,
+ HttpServletResponse response
+ ) throws IOException {
+ response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
+ response.setCharacterEncoding("utf-8");
+ response.setContentType("application/json; charset=UTF-8");
+ ErrorResponse errorResponse = new ErrorResponse(exceptionType.getCode(), exceptionType.getMessage());
+ new ObjectMapper().writeValue(response.getOutputStream(), errorResponse);
+ }
+}
diff --git a/src/main/java/com/ajou/hertz/common/auth/JwtTokenProvider.java b/src/main/java/com/ajou/hertz/common/auth/JwtTokenProvider.java
new file mode 100644
index 0000000..6ac7ede
--- /dev/null
+++ b/src/main/java/com/ajou/hertz/common/auth/JwtTokenProvider.java
@@ -0,0 +1,149 @@
+package com.ajou.hertz.common.auth;
+
+import java.nio.charset.StandardCharsets;
+import java.security.Key;
+import java.sql.Timestamp;
+import java.util.Date;
+
+import org.springframework.beans.factory.annotation.Value;
+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 com.ajou.hertz.common.auth.dto.JwtTokenInfoDto;
+import com.ajou.hertz.common.auth.exception.TokenValidateException;
+import com.ajou.hertz.domain.user.dto.UserDto;
+
+import io.jsonwebtoken.Claims;
+import io.jsonwebtoken.ExpiredJwtException;
+import io.jsonwebtoken.Jwts;
+import io.jsonwebtoken.MalformedJwtException;
+import io.jsonwebtoken.SignatureAlgorithm;
+import io.jsonwebtoken.UnsupportedJwtException;
+import io.jsonwebtoken.security.Keys;
+import io.jsonwebtoken.security.SignatureException;
+import jakarta.annotation.PostConstruct;
+import lombok.RequiredArgsConstructor;
+
+@RequiredArgsConstructor
+@Component
+public class JwtTokenProvider {
+
+ private final UserDetailsService userDetailsService;
+
+ private static final long MINUTE = 1000 * 60L;
+ private static final long HOUR = 60 * MINUTE;
+ private static final long ACCESS_TOKEN_EXPIRED_DURATION = 2 * HOUR; // Access token 만료시간: 2시간
+ private static final String ROLE_CLAIM_KEY = "role";
+
+ @Value("${jwt.secret-key}")
+ private String salt;
+
+ private Key secretKey;
+
+ /**
+ * 객체 초기화, jwt secret key를 Base64로 인코딩
+ */
+ @PostConstruct
+ protected void init() {
+ secretKey = Keys.hmacShaKeyFor(salt.getBytes(StandardCharsets.UTF_8));
+ }
+
+ /**
+ * Jwt access token을 생성하여 반환한다.
+ *
+ * @param userDto 회원 정보가 담긴 dto
+ * @return 생성한 jwt token
+ */
+ public JwtTokenInfoDto createAccessToken(UserDto userDto) {
+ return createJwtToken(userDto, ACCESS_TOKEN_EXPIRED_DURATION);
+ }
+
+ /**
+ * JWT token에서 사용자 정보 조회 후 security login 과정(UsernamePasswordAuthenticationToken)을 수행한다.
+ *
+ * @param token Jwt token
+ * @return Token을 통해 조회한 사용자 정보
+ */
+ public Authentication getAuthentication(String token) {
+ UserDetails principal = userDetailsService.loadUserByUsername(getUsernameFromToken(token));
+ return new UsernamePasswordAuthenticationToken(principal, "", principal.getAuthorities());
+ }
+
+ /**
+ * 토큰의 유효성, 만료일자 검증
+ *
+ * @param token 검증하고자 하는 JWT token
+ * @throws TokenValidateException Token 값이 잘못되거나 만료되어 유효하지 않은 경우
+ */
+ public void validateToken(String token) {
+ try {
+ Jwts
+ .parserBuilder()
+ .setSigningKey(secretKey)
+ .build()
+ .parseClaimsJws(token);
+ } catch (UnsupportedJwtException ex) {
+ throw new TokenValidateException("The claimsJws argument does not represent an Claims JWS", ex);
+ } catch (MalformedJwtException ex) {
+ throw new TokenValidateException("The claimsJws string is not a valid JWS", ex);
+ } catch (SignatureException ex) {
+ throw new TokenValidateException("The claimsJws JWS signature validation fails", ex);
+ } catch (ExpiredJwtException ex) {
+ throw new TokenValidateException(
+ "The specified JWT is a Claims JWT and the Claims has an expiration time before the time this method is invoked.",
+ ex
+ );
+ } catch (IllegalArgumentException ex) {
+ throw new TokenValidateException("The claimsJws string is null or empty or only whitespace", ex);
+ }
+ }
+
+ /**
+ * Subject(socialUid), 로그인 type, token 만료 시간을 전달받아 JWT token을 생성한다.
+ * 현재 access token과 refresh token을 생성할 때 만료 시간 외의 정보는 동일하므로 method를 통일하였다.
+ *
+ * @param userDto 회원 정보가 담긴 dto
+ * @param tokenExpiredDuration Token 만료 시간
+ * @return 생성된 jwt token과 만료 시각이 포함된 JwtTokenInfoDto
객체
+ */
+ private JwtTokenInfoDto createJwtToken(UserDto userDto, Long tokenExpiredDuration) {
+ Date now = new Date();
+ Date expiresAt = new Date(now.getTime() + tokenExpiredDuration);
+ String token = Jwts.builder()
+ .setHeaderParam("typ", "JWT")
+ .setSubject(String.valueOf(userDto.getId()))
+ .claim(ROLE_CLAIM_KEY, userDto.getRoleTypes())
+ .setIssuedAt(now)
+ .setExpiration(expiresAt)
+ .signWith(secretKey, SignatureAlgorithm.HS256)
+ .compact();
+ return new JwtTokenInfoDto(token, new Timestamp(expiresAt.getTime()).toLocalDateTime());
+ }
+
+ /**
+ * 토큰에서 회원 정보(username)를 추출한다. 이 때 username은 회원의 id(PK) 값.
+ *
+ * @param token Jwt token
+ * @return 추출한 회원 정보(username == email)
+ */
+ public String getUsernameFromToken(String token) {
+ return getClaimsFromToken(token).getSubject();
+ }
+
+ /**
+ * Claims 정보를 추출한다.
+ *
+ * @param token 정보를 추출하고자 하는 jwt token
+ * @return token에서 추출한 Claims 정보
+ */
+ private Claims getClaimsFromToken(String token) {
+ return Jwts.parserBuilder()
+ .setSigningKey(secretKey)
+ .build()
+ .parseClaimsJws(token)
+ .getBody();
+ }
+}
diff --git a/src/main/java/com/ajou/hertz/common/auth/UserPrincipal.java b/src/main/java/com/ajou/hertz/common/auth/UserPrincipal.java
new file mode 100644
index 0000000..a526cb3
--- /dev/null
+++ b/src/main/java/com/ajou/hertz/common/auth/UserPrincipal.java
@@ -0,0 +1,63 @@
+package com.ajou.hertz.common.auth;
+
+import java.util.Collection;
+import java.util.stream.Collectors;
+
+import org.springframework.security.core.GrantedAuthority;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+import org.springframework.security.core.userdetails.UserDetails;
+
+import com.ajou.hertz.domain.user.constant.RoleType;
+import com.ajou.hertz.domain.user.dto.UserDto;
+
+import lombok.AllArgsConstructor;
+
+@AllArgsConstructor
+public class UserPrincipal implements UserDetails {
+
+ private UserDto userDto;
+
+ public Long getUserId() {
+ return userDto.getId();
+ }
+
+ @Override
+ public Collection extends GrantedAuthority> getAuthorities() {
+ return userDto
+ .getRoleTypes()
+ .stream()
+ .map(RoleType::getRoleName)
+ .map(SimpleGrantedAuthority::new)
+ .collect(Collectors.toUnmodifiableSet());
+ }
+
+ @Override
+ public String getUsername() {
+ return String.valueOf(getUserId());
+ }
+
+ @Override
+ public String getPassword() {
+ return userDto.getPassword();
+ }
+
+ @Override
+ public boolean isAccountNonExpired() {
+ return true;
+ }
+
+ @Override
+ public boolean isAccountNonLocked() {
+ return true;
+ }
+
+ @Override
+ public boolean isCredentialsNonExpired() {
+ return true;
+ }
+
+ @Override
+ public boolean isEnabled() {
+ return true;
+ }
+}
diff --git a/src/main/java/com/ajou/hertz/common/auth/controller/AuthControllerV1.java b/src/main/java/com/ajou/hertz/common/auth/controller/AuthControllerV1.java
new file mode 100644
index 0000000..cfe424e
--- /dev/null
+++ b/src/main/java/com/ajou/hertz/common/auth/controller/AuthControllerV1.java
@@ -0,0 +1,44 @@
+package com.ajou.hertz.common.auth.controller;
+
+import static com.ajou.hertz.common.constant.GlobalConstants.*;
+
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import com.ajou.hertz.common.auth.dto.JwtTokenInfoDto;
+import com.ajou.hertz.common.auth.dto.request.LoginRequest;
+import com.ajou.hertz.common.auth.dto.response.JwtTokenInfoResponse;
+import com.ajou.hertz.common.auth.service.AuthService;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.responses.ApiResponses;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+
+@Tag(name = "로그인 등 인증 관련 API")
+@RequiredArgsConstructor
+@RequestMapping("/v1/auth")
+@RestController
+public class AuthControllerV1 {
+
+ private final AuthService authService;
+
+ @Operation(
+ summary = "로그인",
+ description = "이메일과 비밀번호를 전달받아 로그인을 진행합니다."
+ )
+ @ApiResponses({
+ @ApiResponse(responseCode = "200", description = "OK"),
+ @ApiResponse(responseCode = "400", description = "[2003] 비밀번호가 일치하지 않는 경우"),
+ @ApiResponse(responseCode = "404", description = "[2202] 이메일에 해당하는 유저를 찾을 수 없는 경우")
+ })
+ @PostMapping(value = "/login", headers = API_MINOR_VERSION_HEADER_NAME + "=" + 1)
+ public JwtTokenInfoResponse loginV1_1(@RequestBody @Valid LoginRequest loginRequest) {
+ JwtTokenInfoDto jwtTokenInfoDto = authService.login(loginRequest);
+ return JwtTokenInfoResponse.from(jwtTokenInfoDto);
+ }
+}
diff --git a/src/main/java/com/ajou/hertz/common/auth/dto/JwtTokenInfoDto.java b/src/main/java/com/ajou/hertz/common/auth/dto/JwtTokenInfoDto.java
new file mode 100644
index 0000000..21ea6e1
--- /dev/null
+++ b/src/main/java/com/ajou/hertz/common/auth/dto/JwtTokenInfoDto.java
@@ -0,0 +1,9 @@
+package com.ajou.hertz.common.auth.dto;
+
+import java.time.LocalDateTime;
+
+public record JwtTokenInfoDto(
+ String token,
+ LocalDateTime expiresAt
+) {
+}
diff --git a/src/main/java/com/ajou/hertz/common/auth/dto/request/LoginRequest.java b/src/main/java/com/ajou/hertz/common/auth/dto/request/LoginRequest.java
new file mode 100644
index 0000000..98006fd
--- /dev/null
+++ b/src/main/java/com/ajou/hertz/common/auth/dto/request/LoginRequest.java
@@ -0,0 +1,27 @@
+package com.ajou.hertz.common.auth.dto.request;
+
+import com.ajou.hertz.common.validator.Password;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.Email;
+import jakarta.validation.constraints.NotBlank;
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@AllArgsConstructor(access = AccessLevel.PRIVATE)
+@NoArgsConstructor(access = AccessLevel.PRIVATE)
+@Getter
+public class LoginRequest {
+
+ @Schema(description = "이메일", example = "example@mail.com")
+ @NotBlank
+ @Email
+ private String email;
+
+ @Schema(description = "비밀번호", example = "1q2w3e4r!")
+ @NotBlank
+ @Password
+ private String password;
+}
diff --git a/src/main/java/com/ajou/hertz/common/auth/dto/response/JwtTokenInfoResponse.java b/src/main/java/com/ajou/hertz/common/auth/dto/response/JwtTokenInfoResponse.java
new file mode 100644
index 0000000..1725c21
--- /dev/null
+++ b/src/main/java/com/ajou/hertz/common/auth/dto/response/JwtTokenInfoResponse.java
@@ -0,0 +1,30 @@
+package com.ajou.hertz.common.auth.dto.response;
+
+import java.time.LocalDateTime;
+
+import com.ajou.hertz.common.auth.dto.JwtTokenInfoDto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@AllArgsConstructor(access = AccessLevel.PRIVATE)
+@NoArgsConstructor(access = AccessLevel.PRIVATE)
+@Getter
+public class JwtTokenInfoResponse {
+
+ @Schema(description = "Token value", example = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIiwicm9sZSI6IlJPTEVfVVNFUiIsImxvZ2luVHlwZSI6IktBS0FPIiwiaWF0IjoxNjc3NDg0NzExLCJleHAiOjE2Nzc1Mjc5MTF9.eM2R_mMRqkPUsMmJN_vm2lAsIGownPJZ6Xu47K6ujrI")
+ private String token;
+
+ @Schema(description = "Token 만료 시각", example = "2023-02-28T17:13:55.473")
+ private LocalDateTime expiresAt;
+
+ public static JwtTokenInfoResponse from(JwtTokenInfoDto jwtTokenInfoDto) {
+ return new JwtTokenInfoResponse(
+ jwtTokenInfoDto.token(),
+ jwtTokenInfoDto.expiresAt()
+ );
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/ajou/hertz/common/auth/exception/PasswordMismatchException.java b/src/main/java/com/ajou/hertz/common/auth/exception/PasswordMismatchException.java
new file mode 100644
index 0000000..e2d86e4
--- /dev/null
+++ b/src/main/java/com/ajou/hertz/common/auth/exception/PasswordMismatchException.java
@@ -0,0 +1,11 @@
+package com.ajou.hertz.common.auth.exception;
+
+import com.ajou.hertz.common.exception.BadRequestException;
+import com.ajou.hertz.common.exception.constant.CustomExceptionType;
+
+public class PasswordMismatchException extends BadRequestException {
+
+ public PasswordMismatchException() {
+ super(CustomExceptionType.PASSWORD_MISMATCH);
+ }
+}
diff --git a/src/main/java/com/ajou/hertz/common/auth/exception/TokenValidateException.java b/src/main/java/com/ajou/hertz/common/auth/exception/TokenValidateException.java
new file mode 100644
index 0000000..fc92d26
--- /dev/null
+++ b/src/main/java/com/ajou/hertz/common/auth/exception/TokenValidateException.java
@@ -0,0 +1,11 @@
+package com.ajou.hertz.common.auth.exception;
+
+import com.ajou.hertz.common.exception.UnauthorizedException;
+import com.ajou.hertz.common.exception.constant.CustomExceptionType;
+
+public class TokenValidateException extends UnauthorizedException {
+
+ public TokenValidateException(String optionalMessage, Throwable cause) {
+ super(CustomExceptionType.TOKEN_VALIDATE, optionalMessage, cause);
+ }
+}
diff --git a/src/main/java/com/ajou/hertz/common/auth/service/AuthService.java b/src/main/java/com/ajou/hertz/common/auth/service/AuthService.java
new file mode 100644
index 0000000..787e4bc
--- /dev/null
+++ b/src/main/java/com/ajou/hertz/common/auth/service/AuthService.java
@@ -0,0 +1,39 @@
+package com.ajou.hertz.common.auth.service;
+
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import com.ajou.hertz.common.auth.JwtTokenProvider;
+import com.ajou.hertz.common.auth.dto.JwtTokenInfoDto;
+import com.ajou.hertz.common.auth.dto.request.LoginRequest;
+import com.ajou.hertz.common.auth.exception.PasswordMismatchException;
+import com.ajou.hertz.domain.user.dto.UserDto;
+import com.ajou.hertz.domain.user.service.UserQueryService;
+
+import lombok.RequiredArgsConstructor;
+
+@RequiredArgsConstructor
+@Transactional
+@Service
+public class AuthService {
+
+ private final UserQueryService userQueryService;
+ private final PasswordEncoder passwordEncoder;
+ private final JwtTokenProvider jwtTokenProvider;
+
+ /**
+ * 로그인을 진행한다.
+ *
+ * @param loginRequest 로그인에 필요한 정보(이메일, 비밀번호)
+ * @return 로그인 성공 시 발급한 access token 정보
+ * @throws PasswordMismatchException 비밀번호가 일치하지 않는 경우
+ */
+ public JwtTokenInfoDto login(LoginRequest loginRequest) {
+ UserDto user = userQueryService.getDtoByEmail(loginRequest.getEmail());
+ if (!passwordEncoder.matches(loginRequest.getPassword(), user.getPassword())) {
+ throw new PasswordMismatchException();
+ }
+ return jwtTokenProvider.createAccessToken(user);
+ }
+}
diff --git a/src/main/java/com/ajou/hertz/common/config/JpaConfig.java b/src/main/java/com/ajou/hertz/common/config/JpaConfig.java
index cdda4a8..f03e44d 100644
--- a/src/main/java/com/ajou/hertz/common/config/JpaConfig.java
+++ b/src/main/java/com/ajou/hertz/common/config/JpaConfig.java
@@ -6,14 +6,32 @@
import org.springframework.context.annotation.Configuration;
import org.springframework.data.domain.AuditorAware;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContext;
+import org.springframework.security.core.context.SecurityContextHolder;
+
+import com.ajou.hertz.common.auth.UserPrincipal;
@EnableJpaAuditing
@Configuration
public class JpaConfig {
- // TODO: 로그인 및 인증/인가 로직 작성 후 변경 필요
@Bean
public AuditorAware auditorAware() {
- return () -> Optional.of(1L);
+ return () -> {
+ Optional