diff --git a/build.gradle b/build.gradle index 55f6b27e..b87517d6 100644 --- a/build.gradle +++ b/build.gradle @@ -55,9 +55,14 @@ dependencies { // Redis implementation 'org.springframework.boot:spring-boot-starter-data-redis' - // Spring Security - //implementation 'org.springframework.boot:spring-boot-starter-security' - // testImplementation 'org.springframework.security:spring-security-test' + //Spring Security + implementation 'org.springframework.boot:spring-boot-starter-security' + testImplementation 'org.springframework.security:spring-security-test' + + // jwt + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' // Mapstruct implementation "org.projectlombok:lombok" diff --git a/src/main/java/clap/server/adapter/inbound/security/CustomGrantedAuthority.java b/src/main/java/clap/server/adapter/inbound/security/CustomGrantedAuthority.java new file mode 100644 index 00000000..2339122e --- /dev/null +++ b/src/main/java/clap/server/adapter/inbound/security/CustomGrantedAuthority.java @@ -0,0 +1,52 @@ +package clap.server.adapter.inbound.security; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.validation.constraints.NotNull; +import org.springframework.security.core.GrantedAuthority; + +import java.io.Serial; +import java.io.Serializable; + +public class CustomGrantedAuthority implements GrantedAuthority, Serializable { + @Serial + private static final long serialVersionUID = 1L; + + private final String role; + + @JsonCreator + public CustomGrantedAuthority( + @JsonProperty("authority") @NotNull + String role + ) { + this.role = role; + } + + @Override + public String getAuthority() { + return role; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + + if (obj instanceof CustomGrantedAuthority cga) { + return this.role.equals(cga.getAuthority()); + } + + return false; + } + + @Override + public int hashCode() { + return this.role.hashCode(); + } + + @Override + public String toString() { + return this.role; + } +} diff --git a/src/main/java/clap/server/adapter/inbound/security/SecurityUserDetails.java b/src/main/java/clap/server/adapter/inbound/security/SecurityUserDetails.java new file mode 100644 index 00000000..152c92ff --- /dev/null +++ b/src/main/java/clap/server/adapter/inbound/security/SecurityUserDetails.java @@ -0,0 +1,93 @@ +package clap.server.adapter.inbound.security; + +import clap.server.adapter.outbound.persistense.entity.member.MemberEntity; +import clap.server.adapter.outbound.persistense.entity.member.constant.MemberStatus; +import com.fasterxml.jackson.annotation.JsonIgnore; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.io.Serial; +import java.util.Collection; +import java.util.List; + +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class SecurityUserDetails implements UserDetails { + @Serial + private static final long serialVersionUID = 1L; + + private Long userId; + private String username; + private Collection authorities; + private boolean accountNonLocked; + + @JsonIgnore + private boolean enabled; + @JsonIgnore + private String password; + @JsonIgnore + private boolean credentialsNonExpired; + @JsonIgnore + private boolean accountNonExpired; + + @Builder + public SecurityUserDetails( + Long userId, + String username, + Collection authorities, + boolean accountNonLocked + ) { + this.userId = userId; + this.username = username; + this.authorities = authorities; + this.accountNonLocked = accountNonLocked; + } + + public static UserDetails from(MemberEntity member) { + return SecurityUserDetails.builder() + .userId(member.getMemberId()) + .username(member.getName()) + .authorities(List.of(new CustomGrantedAuthority(member.getRole().name()))) + .accountNonLocked(member.getStatus().equals(MemberStatus.INACTIVE)) + .build(); + } + + @Override + public Collection getAuthorities() { + return authorities; + } + + @Override + public String getPassword() { + return null; + } + + @Override + public String getUsername() { + return username; + } + + @Override + public boolean isAccountNonExpired() { + throw new UnsupportedOperationException(); + } + + @Override + public boolean isAccountNonLocked() { + return accountNonLocked; + } + + @Override + public boolean isCredentialsNonExpired() { + throw new UnsupportedOperationException(); + } + + @Override + public boolean isEnabled() { + throw new UnsupportedOperationException(); + } +} diff --git a/src/main/java/clap/server/adapter/inbound/security/SecurityUserDetailsService.java b/src/main/java/clap/server/adapter/inbound/security/SecurityUserDetailsService.java new file mode 100644 index 00000000..57d8f5eb --- /dev/null +++ b/src/main/java/clap/server/adapter/inbound/security/SecurityUserDetailsService.java @@ -0,0 +1,23 @@ +package clap.server.adapter.inbound.security; + +import clap.server.adapter.outbound.persistense.repository.member.MemberRepository; +import clap.server.exception.AuthException; +import clap.server.exception.code.MemberErrorCode; +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 SecurityUserDetailsService implements UserDetailsService { + private final MemberRepository loadMemberPort; + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + return loadMemberPort.findById(Long.parseLong(username)) + .map(SecurityUserDetails::from) + .orElseThrow(() -> new AuthException(MemberErrorCode.MEMBER_NOT_FOUND)); + } +} \ No newline at end of file diff --git a/src/main/java/clap/server/adapter/inbound/security/filter/JwtAuthenticationFilter.java b/src/main/java/clap/server/adapter/inbound/security/filter/JwtAuthenticationFilter.java new file mode 100644 index 00000000..7cf491b7 --- /dev/null +++ b/src/main/java/clap/server/adapter/inbound/security/filter/JwtAuthenticationFilter.java @@ -0,0 +1,135 @@ +package clap.server.adapter.inbound.security.filter; + +import clap.server.adapter.outbound.jwt.JwtClaims; +import clap.server.adapter.outbound.jwt.access.AccessTokenClaimKeys; +import clap.server.application.port.outbound.auth.JwtProvider; +import clap.server.exception.JwtException; +import clap.server.exception.code.AuthErrorCode; +import io.jsonwebtoken.Claims; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.constraints.NotNull; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; +import org.springframework.security.access.AccessDeniedException; +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.web.access.AccessDeniedHandler; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +// 요청에서 JWT 토큰을 추출하고 유효성을 검사합니다. +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + private static final String TEMPORARY_TOKEN_ALLOWED_ENDPOINT = "/api/members/initial-password"; + private final UserDetailsService securityUserDetailsService; + private final JwtProvider accessTokenProvider; + private final JwtProvider temporaryTokenProvider; + private final AccessDeniedHandler accessDeniedHandler; + + @Override + protected void doFilterInternal( + @NotNull HttpServletRequest request, + @NotNull HttpServletResponse response, + @NotNull FilterChain filterChain + ) throws ServletException, IOException { + try { + if (isAnonymousRequest(request)) { + filterChain.doFilter(request, response); + return; + } + + String accessToken = resolveAccessToken(request); + + UserDetails userDetails = getUserDetails(accessToken); + authenticateUser(userDetails, request); + } catch (AccessDeniedException e) { + accessDeniedHandler.handle(request, response, e); + return; + } + filterChain.doFilter(request, response); + } + + private boolean isAnonymousRequest(HttpServletRequest request) { + String accessToken = request.getHeader(HttpHeaders.AUTHORIZATION); + return accessToken == null; + } + + private String resolveAccessToken( + HttpServletRequest request + ) throws ServletException { + String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION); + String token = accessTokenProvider.resolveToken(authHeader); + + if (!StringUtils.hasText(token)) { + log.error("EMPTY_ACCESS_TOKEN"); + handleAuthException(AuthErrorCode.EMPTY_ACCESS_KEY); + } + + String requestUrl = request.getRequestURI(); + boolean isTemporaryToken = isTemporaryToken(token); + JwtProvider tokenProvider = isTemporaryToken ? temporaryTokenProvider : accessTokenProvider; + + log.info("Token is Temporary {}", isTemporaryToken); + + if (isTemporaryTokenAllowed(requestUrl) != isTemporaryToken) { + log.error("FORBIDDEN_TEMPORARY_TOKEN_ACCESS"); + handleAuthException(AuthErrorCode.FORBIDDEN_ACCESS_TOKEN); + } + + // TODO: 블랙리스트 토큰 처리 로직 추가 필요 + + if (tokenProvider.isTokenExpired(token)) { + log.error("EXPIRED_TOKEN"); + handleAuthException(AuthErrorCode.EXPIRED_TOKEN); + } + + return token; + } + + + private boolean isTemporaryTokenAllowed(String requestUrl) { + return requestUrl.equals(TEMPORARY_TOKEN_ALLOWED_ENDPOINT); + } + + private boolean isTemporaryToken(String token) { + try { + Claims claims = temporaryTokenProvider.getClaimsFromToken(token); + return claims.get("isTemporary", Boolean.class) != null && claims.get("isTemporary", Boolean.class); + } catch (Exception e) { + return false; + } + } + + private UserDetails getUserDetails(String accessToken) { + JwtProvider tokenProvider = isTemporaryToken(accessToken) ? temporaryTokenProvider : accessTokenProvider; + JwtClaims claims = tokenProvider.parseJwtClaimsFromToken(accessToken); + String memberId = (String) claims.getClaims().get(AccessTokenClaimKeys.USER_ID.getValue()); + return securityUserDetailsService.loadUserByUsername(memberId); + } + + private void authenticateUser(UserDetails userDetails, HttpServletRequest request) { + UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken( + userDetails, null, userDetails.getAuthorities() + ); + + authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(authenticationToken); + } + + private void handleAuthException(AuthErrorCode authErrorCode) throws ServletException { + JwtException exception = new JwtException(authErrorCode); + throw new ServletException(exception); + } +} diff --git a/src/main/java/clap/server/adapter/inbound/security/filter/JwtErrorCodeUtil.java b/src/main/java/clap/server/adapter/inbound/security/filter/JwtErrorCodeUtil.java new file mode 100644 index 00000000..3a342ecb --- /dev/null +++ b/src/main/java/clap/server/adapter/inbound/security/filter/JwtErrorCodeUtil.java @@ -0,0 +1,54 @@ +package clap.server.adapter.inbound.security.filter; +import clap.server.exception.JwtException; +import clap.server.exception.code.AuthErrorCode; +import clap.server.exception.code.BaseErrorCode; +import clap.server.exception.code.CommonErrorCode; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.MalformedJwtException; +import io.jsonwebtoken.UnsupportedJwtException; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import java.security.SignatureException; +import java.util.Map; +import java.util.Optional; + +@Slf4j +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class JwtErrorCodeUtil { + private static final Map, BaseErrorCode> ERROR_CODE_MAP = Map.of( + ExpiredJwtException.class, AuthErrorCode.EXPIRED_TOKEN, + MalformedJwtException.class, AuthErrorCode.MALFORMED_TOKEN, + SignatureException.class, AuthErrorCode.TAMPERED_TOKEN, + UnsupportedJwtException.class, AuthErrorCode.UNSUPPORTED_JWT_TOKEN + ); + + public static BaseErrorCode determineErrorCode(Exception exception, BaseErrorCode defaultErrorCode) { + if (exception instanceof JwtException jwtException) + return jwtException.getErrorCode(); + + Class exceptionClass = exception.getClass(); + return ERROR_CODE_MAP.getOrDefault(exceptionClass, defaultErrorCode); + } + + + public static JwtException determineAuthErrorException(Exception exception) { + return findAuthErrorException(exception).orElseGet( + () -> { + BaseErrorCode errorCode = determineErrorCode(exception, CommonErrorCode.INTERNAL_SERVER_ERROR); + log.debug(exception.getMessage(), exception); + return new JwtException(errorCode); + } + ); + } + + private static Optional findAuthErrorException(Exception exception) { + if (exception instanceof JwtException) { + return Optional.of((JwtException)exception); + } else if (exception.getCause() instanceof JwtException) { + return Optional.of((JwtException)exception.getCause()); + } + return Optional.empty(); + } +} diff --git a/src/main/java/clap/server/adapter/inbound/security/filter/JwtExceptionFilter.java b/src/main/java/clap/server/adapter/inbound/security/filter/JwtExceptionFilter.java new file mode 100644 index 00000000..a75e430d --- /dev/null +++ b/src/main/java/clap/server/adapter/inbound/security/filter/JwtExceptionFilter.java @@ -0,0 +1,42 @@ +package clap.server.adapter.inbound.security.filter; + +import clap.server.exception.JwtException; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Slf4j +@RequiredArgsConstructor +public class JwtExceptionFilter extends OncePerRequestFilter { + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain + ) throws ServletException, IOException { + try { + filterChain.doFilter(request, response); + } catch (Exception e) { + if (!response.isCommitted()) { + JwtException exception = JwtErrorCodeUtil.determineAuthErrorException(e); + sendAuthError(response, exception); + } + } + } + + private void sendAuthError(HttpServletResponse response, JwtException e) throws IOException { + if (!response.isCommitted()) { + response.setContentType("application/json;charset=UTF-8"); + response.setStatus(e.getErrorCode().getHttpStatus().value()); + response.getWriter().write(e.getErrorCode().getCustomCode()); + } + } + +} diff --git a/src/main/java/clap/server/adapter/inbound/security/handler/JwtAccessDeniedHandler.java b/src/main/java/clap/server/adapter/inbound/security/handler/JwtAccessDeniedHandler.java new file mode 100644 index 00000000..728c3218 --- /dev/null +++ b/src/main/java/clap/server/adapter/inbound/security/handler/JwtAccessDeniedHandler.java @@ -0,0 +1,30 @@ +package clap.server.adapter.inbound.security.handler; + +import clap.server.exception.code.AuthErrorCode; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; + +import java.io.IOException; + +@Slf4j +@RequiredArgsConstructor +public class JwtAccessDeniedHandler implements AccessDeniedHandler { + + @Override + public void handle( + HttpServletRequest request, + HttpServletResponse response, + AccessDeniedException accessDeniedException + ) throws IOException, ServletException { + AuthErrorCode errorCode = AuthErrorCode.FORBIDDEN; + + response.setContentType("application/json;charset=UTF-8"); + response.setStatus(errorCode.getHttpStatus().value()); + response.getWriter().write(errorCode.getCustomCode()); + } +} diff --git a/src/main/java/clap/server/adapter/inbound/security/handler/JwtAuthenticationEntryPoint.java b/src/main/java/clap/server/adapter/inbound/security/handler/JwtAuthenticationEntryPoint.java new file mode 100644 index 00000000..74ba4fc2 --- /dev/null +++ b/src/main/java/clap/server/adapter/inbound/security/handler/JwtAuthenticationEntryPoint.java @@ -0,0 +1,30 @@ +package clap.server.adapter.inbound.security.handler; + +import clap.server.exception.code.AuthErrorCode; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; + +import java.io.IOException; + +@Slf4j +@RequiredArgsConstructor +public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { + + @Override + public void commence( + HttpServletRequest request, + HttpServletResponse response, + AuthenticationException authException + ) throws IOException, ServletException { + AuthErrorCode errorCode = AuthErrorCode.UNAUTHORIZED; + + response.setContentType("application/json;charset=UTF-8"); + response.setStatus(errorCode.getHttpStatus().value()); + response.getWriter().write(errorCode.getCustomCode()); + } +} diff --git a/src/main/java/clap/server/adapter/inbound/web/admin/RegisterMemberController.java b/src/main/java/clap/server/adapter/inbound/web/admin/RegisterMemberController.java index 22d45791..e26db4f4 100644 --- a/src/main/java/clap/server/adapter/inbound/web/admin/RegisterMemberController.java +++ b/src/main/java/clap/server/adapter/inbound/web/admin/RegisterMemberController.java @@ -1,21 +1,31 @@ package clap.server.adapter.inbound.web.admin; +import clap.server.adapter.inbound.security.SecurityUserDetails; import clap.server.adapter.inbound.web.dto.admin.RegisterMemberRequest; -import clap.server.application.RegisterMemberService; import clap.server.application.port.inbound.management.RegisterMemberUsecase; import clap.server.common.annotation.architecture.WebAdapter; +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.web.bind.annotation.*; +import org.springframework.security.access.annotation.Secured; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +@Tag(name = "회원 관리 - 등록") @WebAdapter @RequiredArgsConstructor @RequestMapping("/api/managements") public class RegisterMemberController { private final RegisterMemberUsecase registerMemberUsecase; + @Operation(summary = "단일 회원 등록 API") @PostMapping("/members") - public void registerMember(@RequestBody @Valid RegisterMemberRequest request) { - registerMemberUsecase.registerMember(1l, request); + @Secured("ROLE_ADMIN") + public void registerMember(@AuthenticationPrincipal SecurityUserDetails userInfo, + @RequestBody @Valid RegisterMemberRequest request){ + registerMemberUsecase.registerMember(userInfo.getUserId(), request); } } \ No newline at end of file diff --git a/src/main/java/clap/server/adapter/inbound/web/auth/AuthController.java b/src/main/java/clap/server/adapter/inbound/web/auth/AuthController.java index 6bc5a5e9..d0694f3c 100644 --- a/src/main/java/clap/server/adapter/inbound/web/auth/AuthController.java +++ b/src/main/java/clap/server/adapter/inbound/web/auth/AuthController.java @@ -1,8 +1,28 @@ package clap.server.adapter.inbound.web.auth; +import clap.server.adapter.inbound.web.dto.auth.LoginRequest; +import clap.server.adapter.inbound.web.dto.auth.LoginResponse; +import clap.server.application.port.inbound.auth.AuthUsecase; import clap.server.common.annotation.architecture.WebAdapter; -import org.springframework.web.bind.annotation.RestController; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +@Tag(name = "로그인 / 로그아웃 / 토큰 재발급") @WebAdapter +@RequiredArgsConstructor +@RequestMapping("/api/auths") public class AuthController { + private final AuthUsecase authUsecase; + + @Operation(summary = "로그인 API") + @PostMapping("/login") + public ResponseEntity login(@RequestBody LoginRequest request) { + return ResponseEntity.ok(authUsecase.login(request.nickname(), request.password())); + } + } diff --git a/src/main/java/clap/server/adapter/inbound/web/auth/ResetPasswordController.java b/src/main/java/clap/server/adapter/inbound/web/auth/ResetPasswordController.java deleted file mode 100644 index ce996840..00000000 --- a/src/main/java/clap/server/adapter/inbound/web/auth/ResetPasswordController.java +++ /dev/null @@ -1,8 +0,0 @@ -package clap.server.adapter.inbound.web.auth; - -import clap.server.common.annotation.architecture.WebAdapter; -import org.springframework.web.bind.annotation.RestController; - -@WebAdapter -public class ResetPasswordController { -} diff --git a/src/main/java/clap/server/adapter/inbound/web/dto/admin/RegisterMemberRequest.java b/src/main/java/clap/server/adapter/inbound/web/dto/admin/RegisterMemberRequest.java index 3a2c4097..26978089 100644 --- a/src/main/java/clap/server/adapter/inbound/web/dto/admin/RegisterMemberRequest.java +++ b/src/main/java/clap/server/adapter/inbound/web/dto/admin/RegisterMemberRequest.java @@ -1,23 +1,30 @@ package clap.server.adapter.inbound.web.dto.admin; import clap.server.adapter.outbound.persistense.entity.member.constant.MemberRole; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; public record RegisterMemberRequest( - @NotBlank + @NotBlank @Schema(description = "회원 이름") String name, @NotBlank + @Pattern(regexp = "^[a-zA-Z0-9_+&*-]+(?:\\.[a-zA-Z0-9_+&*-]+)*@(?:[a-zA-Z0-9-]+\\.)+[a-zA-Z]{2,7}$", + message = "올바른 이메일 형식이 아닙니다.") + @Schema(description = "회원 이메일") String email, - @NotBlank + @NotBlank @Schema(description = "회원 닉네임, 로그인할 때 쓰입니다.") + @Pattern(regexp = "^[a-z]{3,10}\\.[a-z]{1,5}$", + message = "올바른 닉네임 형식이 아닙니다.") String nickname, - @NotNull + @NotNull @Schema(description = "승인 권한 여부") Boolean isReviewer, - @NotNull + @NotNull @Schema(description = "부서 ID") Long departmentId, - @NotNull + @NotNull @Schema(description = "회원 역할") MemberRole role, - @NotBlank + @NotBlank @Schema(description = "회원 직책") String departmentRole ) { } diff --git a/src/main/java/clap/server/adapter/inbound/web/dto/auth/LoginRequest.java b/src/main/java/clap/server/adapter/inbound/web/dto/auth/LoginRequest.java new file mode 100644 index 00000000..ad777883 --- /dev/null +++ b/src/main/java/clap/server/adapter/inbound/web/dto/auth/LoginRequest.java @@ -0,0 +1,11 @@ +package clap.server.adapter.inbound.web.dto.auth; + +import jakarta.validation.constraints.NotNull; + +public record LoginRequest( + @NotNull + String nickname, + @NotNull + String password +) { +} diff --git a/src/main/java/clap/server/adapter/inbound/web/dto/auth/LoginResponse.java b/src/main/java/clap/server/adapter/inbound/web/dto/auth/LoginResponse.java new file mode 100644 index 00000000..abba4b64 --- /dev/null +++ b/src/main/java/clap/server/adapter/inbound/web/dto/auth/LoginResponse.java @@ -0,0 +1,11 @@ +package clap.server.adapter.inbound.web.dto.auth; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record LoginResponse( + String accessToken, + String refreshToken, + @Schema(description = "회원 정보") + MemberInfoResponse memberInfo +) {} + diff --git a/src/main/java/clap/server/adapter/inbound/web/dto/auth/MemberInfoResponse.java b/src/main/java/clap/server/adapter/inbound/web/dto/auth/MemberInfoResponse.java new file mode 100644 index 00000000..e8dee086 --- /dev/null +++ b/src/main/java/clap/server/adapter/inbound/web/dto/auth/MemberInfoResponse.java @@ -0,0 +1,20 @@ +package clap.server.adapter.inbound.web.dto.auth; + +import clap.server.adapter.outbound.persistense.entity.member.constant.MemberRole; +import clap.server.adapter.outbound.persistense.entity.member.constant.MemberStatus; +import io.swagger.v3.oas.annotations.media.Schema; + +public record MemberInfoResponse( + @Schema(description = "회원 ID") + Long memberId, + @Schema(description = "회원 이름") + String memberName, + @Schema(description = "회원 닉네임, 로그인에 쓰입니다") + String nickname, + @Schema(description = "회원 프로필 이미지") + String imageUrl, + @Schema(description = "회원 역할") + MemberRole memberRole, + @Schema(description = "회원 상태") + MemberStatus memberStatus +) {} diff --git a/src/main/java/clap/server/adapter/inbound/web/dto/task/CreateTaskResponse.java b/src/main/java/clap/server/adapter/inbound/web/dto/task/CreateTaskResponse.java index a58f1a79..1c7d2843 100644 --- a/src/main/java/clap/server/adapter/inbound/web/dto/task/CreateTaskResponse.java +++ b/src/main/java/clap/server/adapter/inbound/web/dto/task/CreateTaskResponse.java @@ -1,7 +1,5 @@ package clap.server.adapter.inbound.web.dto.task; - - public record CreateTaskResponse( Long taskId, Long categoryId, diff --git a/src/main/java/clap/server/adapter/inbound/web/member/ResetPasswordController.java b/src/main/java/clap/server/adapter/inbound/web/member/ResetPasswordController.java new file mode 100644 index 00000000..774b72e6 --- /dev/null +++ b/src/main/java/clap/server/adapter/inbound/web/member/ResetPasswordController.java @@ -0,0 +1,37 @@ +package clap.server.adapter.inbound.web.member; + +import clap.server.adapter.inbound.security.SecurityUserDetails; +import clap.server.application.port.inbound.auth.ResetPasswordUsecase; +import clap.server.common.annotation.architecture.WebAdapter; +import clap.server.common.annotation.validation.password.ValidPassword; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.constraints.NotBlank; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; + +@Tag(name = "비밀번호 재설정") +@WebAdapter +@RequiredArgsConstructor +@RequestMapping("/api") +public class ResetPasswordController { + private final ResetPasswordUsecase resetPasswordUsecase; + + @Operation(summary = "초기 로그인 후 비밀번호 재설정 API", description = "swagger에서 따옴표를 포함하지 않고 요청합니다.") + @PatchMapping("/members/initial-password") + public void resetPasswordAndActivateMember(@AuthenticationPrincipal SecurityUserDetails userInfo, + @RequestBody @NotBlank @ValidPassword String password) { + resetPasswordUsecase.resetPasswordAndActivateMember(userInfo.getUserId(), password); + } + + @Operation(summary = "비밀번호 재설정 API", description = "swagger에서 따옴표를 포함하지 않고 요청합니다.") + @PatchMapping("/members/password") + public void resetPassword(@AuthenticationPrincipal SecurityUserDetails userInfo, + @RequestBody @NotBlank @ValidPassword String password) { + resetPasswordUsecase.resetPassword(userInfo.getUserId(), password); + } + +} diff --git a/src/main/java/clap/server/adapter/outbound/infrastructure/redis/refresh/RefreshTokenAdapter.java b/src/main/java/clap/server/adapter/outbound/infrastructure/redis/refresh/RefreshTokenAdapter.java new file mode 100644 index 00000000..94d4f4d0 --- /dev/null +++ b/src/main/java/clap/server/adapter/outbound/infrastructure/redis/refresh/RefreshTokenAdapter.java @@ -0,0 +1,32 @@ +package clap.server.adapter.outbound.infrastructure.redis.refresh; + +import clap.server.application.port.outbound.auth.CommandRefreshTokenPort; +import clap.server.application.port.outbound.auth.LoadRefreshTokenPort; +import clap.server.domain.model.auth.RefreshToken; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@Slf4j +@Component +@RequiredArgsConstructor +public class RefreshTokenAdapter implements CommandRefreshTokenPort, LoadRefreshTokenPort { + private final RefreshTokenRepository refreshTokenRepository; + private final RefreshTokenMapper refreshTokenMapper; + + public void save(RefreshToken refreshToken) { + RefreshTokenEntity refreshTokenEntity = refreshTokenMapper.toEntity(refreshToken); + refreshTokenRepository.save(refreshTokenEntity); + } + + public void delete(RefreshToken refreshToken) { + RefreshTokenEntity refreshTokenEntity = refreshTokenMapper.toEntity(refreshToken); + refreshTokenRepository.delete(refreshTokenEntity); + } + + public Optional findByMemberId(Long memberId) { + return refreshTokenRepository.findById(memberId).map(refreshTokenMapper::toDomain); + } +} diff --git a/src/main/java/clap/server/adapter/outbound/infrastructure/redis/refresh/RefreshTokenEntity.java b/src/main/java/clap/server/adapter/outbound/infrastructure/redis/refresh/RefreshTokenEntity.java new file mode 100644 index 00000000..2479d079 --- /dev/null +++ b/src/main/java/clap/server/adapter/outbound/infrastructure/redis/refresh/RefreshTokenEntity.java @@ -0,0 +1,20 @@ +package clap.server.adapter.outbound.infrastructure.redis.refresh; + +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.ToString; +import org.springframework.data.annotation.Id; +import org.springframework.data.redis.core.RedisHash; + +@Getter +@RedisHash("refreshToken") +@ToString(of = {"memberId", "token", "ttl"}) +@EqualsAndHashCode(of = {"memberId", "token"}) +public class RefreshTokenEntity { + + @Id + private Long memberId; + private String token; + + private long ttl; +} diff --git a/src/main/java/clap/server/adapter/outbound/infrastructure/redis/refresh/RefreshTokenMapper.java b/src/main/java/clap/server/adapter/outbound/infrastructure/redis/refresh/RefreshTokenMapper.java new file mode 100644 index 00000000..52115015 --- /dev/null +++ b/src/main/java/clap/server/adapter/outbound/infrastructure/redis/refresh/RefreshTokenMapper.java @@ -0,0 +1,15 @@ +package clap.server.adapter.outbound.infrastructure.redis.refresh; + + +import clap.server.adapter.outbound.persistense.mapper.MemberPersistenceMapper; +import clap.server.domain.model.auth.RefreshToken; +import org.mapstruct.InheritInverseConfiguration; +import org.mapstruct.Mapper; + +@Mapper(componentModel = "spring", uses = {MemberPersistenceMapper.class}) +public interface RefreshTokenMapper { + @InheritInverseConfiguration + RefreshToken toDomain(final RefreshTokenEntity entity); + + RefreshTokenEntity toEntity(final RefreshToken domain); +} diff --git a/src/main/java/clap/server/adapter/outbound/infrastructure/redis/refresh/RefreshTokenRepository.java b/src/main/java/clap/server/adapter/outbound/infrastructure/redis/refresh/RefreshTokenRepository.java new file mode 100644 index 00000000..0fda79c3 --- /dev/null +++ b/src/main/java/clap/server/adapter/outbound/infrastructure/redis/refresh/RefreshTokenRepository.java @@ -0,0 +1,6 @@ +package clap.server.adapter.outbound.infrastructure.redis.refresh; + +import org.springframework.data.repository.CrudRepository; + +public interface RefreshTokenRepository extends CrudRepository{ +} diff --git a/src/main/java/clap/server/adapter/outbound/jwt/JwtClaims.java b/src/main/java/clap/server/adapter/outbound/jwt/JwtClaims.java new file mode 100644 index 00000000..033c703f --- /dev/null +++ b/src/main/java/clap/server/adapter/outbound/jwt/JwtClaims.java @@ -0,0 +1,7 @@ +package clap.server.adapter.outbound.jwt; + +import java.util.Map; + +public interface JwtClaims { + Map getClaims(); +} diff --git a/src/main/java/clap/server/adapter/outbound/jwt/access/AccessTokenClaim.java b/src/main/java/clap/server/adapter/outbound/jwt/access/AccessTokenClaim.java new file mode 100644 index 00000000..2b307a6f --- /dev/null +++ b/src/main/java/clap/server/adapter/outbound/jwt/access/AccessTokenClaim.java @@ -0,0 +1,24 @@ +package clap.server.adapter.outbound.jwt.access; + +import clap.server.adapter.outbound.jwt.JwtClaims; +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; + +import java.util.Map; + +@RequiredArgsConstructor(access = AccessLevel.PRIVATE) +public class AccessTokenClaim implements JwtClaims { + private final Map claims; + + public static AccessTokenClaim of(Long memberId) { + Map claims = Map.of( + AccessTokenClaimKeys.USER_ID.getValue(), memberId.toString() + ); + return new AccessTokenClaim(claims); + } + + @Override + public Map getClaims() { + return claims; + } +} diff --git a/src/main/java/clap/server/adapter/outbound/jwt/access/AccessTokenClaimKeys.java b/src/main/java/clap/server/adapter/outbound/jwt/access/AccessTokenClaimKeys.java new file mode 100644 index 00000000..4fe250f9 --- /dev/null +++ b/src/main/java/clap/server/adapter/outbound/jwt/access/AccessTokenClaimKeys.java @@ -0,0 +1,14 @@ +package clap.server.adapter.outbound.jwt.access; + +import lombok.Getter; + +@Getter +public enum AccessTokenClaimKeys { + USER_ID("id"); + + private final String value; + + AccessTokenClaimKeys(String value) { + this.value = value; + } +} diff --git a/src/main/java/clap/server/adapter/outbound/jwt/access/AccessTokenProvider.java b/src/main/java/clap/server/adapter/outbound/jwt/access/AccessTokenProvider.java new file mode 100644 index 00000000..918555b3 --- /dev/null +++ b/src/main/java/clap/server/adapter/outbound/jwt/access/AccessTokenProvider.java @@ -0,0 +1,103 @@ +package clap.server.adapter.outbound.jwt.access; + +import clap.server.adapter.outbound.jwt.JwtClaims; +import clap.server.application.port.outbound.auth.JwtProvider; +import clap.server.common.annotation.jwt.AccessTokenStrategy; +import clap.server.common.utils.DateUtil; +import clap.server.exception.JwtException; +import clap.server.exception.code.AuthErrorCode; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.Base64; +import java.util.Date; +import java.util.Map; + +import static clap.server.adapter.outbound.jwt.access.AccessTokenClaimKeys.USER_ID; + +@Slf4j +@Component +@AccessTokenStrategy +public class AccessTokenProvider implements JwtProvider { + private final SecretKey secretKey; + private final Duration tokenExpiration; + + public AccessTokenProvider( + @Value("${jwt.secret-key.access-token}") String jwtSecretKey, + @Value("${jwt.expiration-time.access-token}") Duration tokenExpiration + ) { + byte[] keyBytes = Base64.getDecoder().decode(jwtSecretKey); + this.secretKey = Keys.hmacShaKeyFor(keyBytes); + this.tokenExpiration = tokenExpiration; + } + + @Override + public String createToken(JwtClaims claims) { + Date now = new Date(); + + return Jwts.builder() + .setHeader(createHeader()) + .setClaims(claims.getClaims()) + .signWith(secretKey) + .setExpiration(createExpirationDate(now, tokenExpiration.toMillis())) + .compact(); + } + + @Override + public JwtClaims parseJwtClaimsFromToken(String token) { + Claims claims = getClaimsFromToken(token); + return AccessTokenClaim.of( + Long.parseLong(claims.get(USER_ID.getValue(), String.class)) + ); + } + + @Override + public LocalDateTime getExpiredDate(String token) { + Claims claims = getClaimsFromToken(token); + return DateUtil.toLocalDateTime(claims.getExpiration()); + } + + @Override + public boolean isTokenExpired(String token) { + try { + Claims claims = getClaimsFromToken(token); + return claims.getExpiration().before(new Date()); + } catch (Exception e) { + log.error("Token is expired: {}", e.getMessage()); + throw new JwtException(AuthErrorCode.EMPTY_ACCESS_KEY); + } + } + + @Override + public Claims getClaimsFromToken(String token) { + try { + return Jwts.parserBuilder() + .setSigningKey(secretKey) + .build() + .parseClaimsJws(token) + .getBody(); + } catch (Exception e) { + log.warn("Token is invalid: {}", e.getMessage()); + throw new JwtException(AuthErrorCode.INVALID_TOKEN); + } + } + + private Map createHeader() { + return Map.of( + "typ", "JWT", + "alg", "HS256", + "regDate", System.currentTimeMillis() + ); + } + + private Date createExpirationDate(Date now, long expirationTime) { + return new Date(now.getTime() + expirationTime); + } +} diff --git a/src/main/java/clap/server/adapter/outbound/jwt/access/temporary/TemporaryTokenClaim.java b/src/main/java/clap/server/adapter/outbound/jwt/access/temporary/TemporaryTokenClaim.java new file mode 100644 index 00000000..f2ca39a6 --- /dev/null +++ b/src/main/java/clap/server/adapter/outbound/jwt/access/temporary/TemporaryTokenClaim.java @@ -0,0 +1,34 @@ +package clap.server.adapter.outbound.jwt.access.temporary; + +import clap.server.adapter.outbound.jwt.JwtClaims; +import clap.server.adapter.outbound.jwt.access.AccessTokenClaimKeys; +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; + +import java.util.HashMap; +import java.util.Map; + +@RequiredArgsConstructor(access = AccessLevel.PRIVATE) +public class TemporaryTokenClaim implements JwtClaims { + private final Map claims; + + public static TemporaryTokenClaim of(Long memberId) { + Map claims = new HashMap<>(); + claims.put(AccessTokenClaimKeys.USER_ID.getValue(), memberId.toString()); + claims.put(TemporaryTokenClaimKeys.IS_TEMPORARY.getValue(), true); + return new TemporaryTokenClaim(claims); + } + + @Override + public Map getClaims() { + return claims; + } + + public Long getMemberId() { + return Long.parseLong((String) claims.get(AccessTokenClaimKeys.USER_ID.getValue())); + } + + public boolean isTemporary() { + return (boolean) claims.get(TemporaryTokenClaimKeys.IS_TEMPORARY.getValue()); + } +} diff --git a/src/main/java/clap/server/adapter/outbound/jwt/access/temporary/TemporaryTokenClaimKeys.java b/src/main/java/clap/server/adapter/outbound/jwt/access/temporary/TemporaryTokenClaimKeys.java new file mode 100644 index 00000000..502a06be --- /dev/null +++ b/src/main/java/clap/server/adapter/outbound/jwt/access/temporary/TemporaryTokenClaimKeys.java @@ -0,0 +1,14 @@ +package clap.server.adapter.outbound.jwt.access.temporary; + +import lombok.Getter; + +@Getter +public enum TemporaryTokenClaimKeys { + IS_TEMPORARY("isTemporary"); + + private final String value; + + TemporaryTokenClaimKeys(String value) { + this.value = value; + } +} \ No newline at end of file diff --git a/src/main/java/clap/server/adapter/outbound/jwt/access/temporary/TemporaryTokenProvider.java b/src/main/java/clap/server/adapter/outbound/jwt/access/temporary/TemporaryTokenProvider.java new file mode 100644 index 00000000..6dd921ae --- /dev/null +++ b/src/main/java/clap/server/adapter/outbound/jwt/access/temporary/TemporaryTokenProvider.java @@ -0,0 +1,103 @@ +package clap.server.adapter.outbound.jwt.access.temporary; + +import clap.server.adapter.outbound.jwt.JwtClaims; +import clap.server.application.port.outbound.auth.JwtProvider; +import clap.server.common.annotation.jwt.TemporaryTokenStrategy; +import clap.server.common.utils.DateUtil; +import clap.server.exception.JwtException; +import clap.server.exception.code.AuthErrorCode; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.Base64; +import java.util.Date; +import java.util.Map; + +import static clap.server.adapter.outbound.jwt.access.AccessTokenClaimKeys.USER_ID; + +@Slf4j +@Component +@TemporaryTokenStrategy +public class TemporaryTokenProvider implements JwtProvider { + private final SecretKey secretKey; + private final Duration tokenExpiration; + + public TemporaryTokenProvider( + @Value("${jwt.secret-key.temporary-token}") String jwtSecretKey, + @Value("${jwt.expiration-time.temporary-token}") Duration tokenExpiration + ) { + byte[] keyBytes = Base64.getDecoder().decode(jwtSecretKey); + this.secretKey = Keys.hmacShaKeyFor(keyBytes); + this.tokenExpiration = tokenExpiration; + } + + @Override + public String createToken(JwtClaims claims) { + Date now = new Date(); + + return Jwts.builder() + .setHeader(createHeader()) + .setClaims(claims.getClaims()) + .signWith(secretKey) + .setExpiration(createExpirationDate(now, tokenExpiration.toMillis())) + .compact(); + } + + @Override + public JwtClaims parseJwtClaimsFromToken(String token) { + Claims claims = getClaimsFromToken(token); + return TemporaryTokenClaim.of( + Long.parseLong(claims.get(USER_ID.getValue(), String.class)) + ); + } + + @Override + public LocalDateTime getExpiredDate(String token) { + Claims claims = getClaimsFromToken(token); + return DateUtil.toLocalDateTime(claims.getExpiration()); + } + + @Override + public boolean isTokenExpired(String token) { + try { + Claims claims = getClaimsFromToken(token); + return claims.getExpiration().before(new Date()); + } catch (Exception e) { + log.error("Token is expired: {}", e.getMessage()); + throw new JwtException(AuthErrorCode.EMPTY_ACCESS_KEY); + } + } + + @Override + public Claims getClaimsFromToken(String token) { + try { + return Jwts.parserBuilder() + .setSigningKey(secretKey) + .build() + .parseClaimsJws(token) + .getBody(); + } catch (Exception e) { + log.warn("Token is invalid: {}", e.getMessage()); + throw new JwtException(AuthErrorCode.INVALID_TOKEN); + } + } + + private Map createHeader() { + return Map.of( + "typ", "JWT", + "alg", "HS256", + "regDate", System.currentTimeMillis() + ); + } + + private Date createExpirationDate(Date now, long expirationTime) { + return new Date(now.getTime() + expirationTime); + } +} diff --git a/src/main/java/clap/server/adapter/outbound/jwt/refresh/RefreshTokenClaim.java b/src/main/java/clap/server/adapter/outbound/jwt/refresh/RefreshTokenClaim.java new file mode 100644 index 00000000..a63f1cc6 --- /dev/null +++ b/src/main/java/clap/server/adapter/outbound/jwt/refresh/RefreshTokenClaim.java @@ -0,0 +1,24 @@ +package clap.server.adapter.outbound.jwt.refresh; + +import clap.server.adapter.outbound.jwt.JwtClaims; +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; + +import java.util.Map; + +@RequiredArgsConstructor(access = AccessLevel.PRIVATE) +public class RefreshTokenClaim implements JwtClaims { + private final Map claims; + + public static RefreshTokenClaim of(Long memberId) { + Map claims = Map.of( + RefreshTokenClaimKeys.USER_ID.getValue(), memberId.toString() + ); + return new RefreshTokenClaim(claims); + } + + @Override + public Map getClaims() { + return claims; + } +} diff --git a/src/main/java/clap/server/adapter/outbound/jwt/refresh/RefreshTokenClaimKeys.java b/src/main/java/clap/server/adapter/outbound/jwt/refresh/RefreshTokenClaimKeys.java new file mode 100644 index 00000000..40a79916 --- /dev/null +++ b/src/main/java/clap/server/adapter/outbound/jwt/refresh/RefreshTokenClaimKeys.java @@ -0,0 +1,14 @@ +package clap.server.adapter.outbound.jwt.refresh; + +import lombok.Getter; + +@Getter +public enum RefreshTokenClaimKeys { + USER_ID("id"); + + private final String value; + + RefreshTokenClaimKeys(String value) { + this.value = value; + } +} diff --git a/src/main/java/clap/server/adapter/outbound/jwt/refresh/RefreshTokenProvider.java b/src/main/java/clap/server/adapter/outbound/jwt/refresh/RefreshTokenProvider.java new file mode 100644 index 00000000..f434181b --- /dev/null +++ b/src/main/java/clap/server/adapter/outbound/jwt/refresh/RefreshTokenProvider.java @@ -0,0 +1,100 @@ +package clap.server.adapter.outbound.jwt.refresh; + +import clap.server.adapter.outbound.jwt.JwtClaims; +import clap.server.application.port.outbound.auth.JwtProvider; +import clap.server.common.annotation.jwt.RefreshTokenStrategy; +import clap.server.common.utils.DateUtil; +import clap.server.exception.JwtException; +import clap.server.exception.code.AuthErrorCode; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.time.LocalDateTime; +import java.util.Base64; +import java.util.Date; +import java.util.Map; + +@Slf4j +@Component +@RefreshTokenStrategy +public class RefreshTokenProvider implements JwtProvider { + private final SecretKey secretKey; + private final Long tokenExpiration; + + public RefreshTokenProvider( + @Value("${jwt.secret-key.refresh-token}") String jwtSecretKey, + @Value("${jwt.expiration-time.refresh-token}") Long tokenExpiration + ) { + final byte[] secretKeyBytes = Base64.getDecoder().decode(jwtSecretKey); + this.secretKey = Keys.hmacShaKeyFor(secretKeyBytes); + this.tokenExpiration = tokenExpiration; + } + + @Override + public String createToken(JwtClaims claims) { + Date now = new Date(); + + return Jwts.builder() + .setHeader(createHeader()) + .setClaims(claims.getClaims()) + .signWith(secretKey) + .setExpiration(createExpireDate(now, tokenExpiration)) + .compact(); + } + + @Override + public JwtClaims parseJwtClaimsFromToken(String token) { + Claims claims = getClaimsFromToken(token); + return RefreshTokenClaim.of( + Long.parseLong(claims.get(RefreshTokenClaimKeys.USER_ID.getValue()).toString()) + ); + } + + @Override + public LocalDateTime getExpiredDate(String token) { + Claims claims = getClaimsFromToken(token); + return DateUtil.toLocalDateTime(claims.getExpiration()); + } + + @Override + public boolean isTokenExpired(String token) { + try { + Claims claims = getClaimsFromToken(token); + return claims.getExpiration().before(new Date()); + } catch (Exception e) { + log.error("Token is expired: {}", e.getMessage()); + throw new JwtException(AuthErrorCode.EMPTY_ACCESS_KEY); + } + } + + @Override + public Claims getClaimsFromToken(String token) { + try { + return Jwts.parserBuilder() + .setSigningKey(secretKey) + .build() + .parseClaimsJws(token) + .getBody(); + } catch (Exception e) { + log.error("Token parsing error: {}", e.getMessage()); + throw new JwtException(AuthErrorCode.INVALID_TOKEN); + } + } + + private Map createHeader() { + return Map.of( + "typ", "JWT", + "alg", "HS256", + "regDate", System.currentTimeMillis() + ); + } + + private Date createExpireDate(final Date now, long expirationTime) { + return new Date(now.getTime() + expirationTime); + } +} diff --git a/src/main/java/clap/server/adapter/outbound/persistense/MemberPersistenceAdapter.java b/src/main/java/clap/server/adapter/outbound/persistense/MemberPersistenceAdapter.java index de0cb0ac..d1a0dd67 100644 --- a/src/main/java/clap/server/adapter/outbound/persistense/MemberPersistenceAdapter.java +++ b/src/main/java/clap/server/adapter/outbound/persistense/MemberPersistenceAdapter.java @@ -30,6 +30,12 @@ public Optional findActiveMemberById(final Long id) { return memberEntity.map(memberPersistenceMapper::toDomain); } + @Override + public Optional findByNickname(final String nickname) { + Optional memberEntity = memberRepository.findByNickname(nickname); + return memberEntity.map(memberPersistenceMapper::toDomain); + } + @Override public void save(final Member member) { MemberEntity memberEntity = memberPersistenceMapper.toEntity(member); diff --git a/src/main/java/clap/server/adapter/outbound/persistense/entity/member/constant/Password.java b/src/main/java/clap/server/adapter/outbound/persistense/entity/member/constant/Password.java deleted file mode 100644 index 38a145dd..00000000 --- a/src/main/java/clap/server/adapter/outbound/persistense/entity/member/constant/Password.java +++ /dev/null @@ -1,32 +0,0 @@ -//package clap.server.adapter.out.persistense.entity.member.constant; -// -//import jakarta.persistence.Column; -//import jakarta.persistence.Embeddable; -//import lombok.AccessLevel; -//import lombok.Getter; -//import lombok.NoArgsConstructor; -//import org.springframework.security.crypto.factory.PasswordEncoderFactories; -//import org.springframework.security.crypto.password.PasswordEncoder; -// -//@Getter -//@NoArgsConstructor(access = AccessLevel.PROTECTED) -//@Embeddable -//public class Password { -// -// public static final PasswordEncoder ENCODER = PasswordEncoderFactories.createDelegatingPasswordEncoder(); -// -// @Column(name = "password", nullable = false) -// private String value; -// -// private Password(String value) { -// this.value = value; -// } -// -// public static Password encrypt(String value, PasswordEncoder encoder) { -// return new Password(encoder.encode(value)); -// } -// -// public boolean isSamePassword(String password, PasswordEncoder encoder) { -// return encoder.matches(password, this.value); -// } -//} \ No newline at end of file diff --git a/src/main/java/clap/server/adapter/outbound/persistense/mapper/MemberPersistenceMapper.java b/src/main/java/clap/server/adapter/outbound/persistense/mapper/MemberPersistenceMapper.java index 21a53255..ab40ab75 100644 --- a/src/main/java/clap/server/adapter/outbound/persistense/mapper/MemberPersistenceMapper.java +++ b/src/main/java/clap/server/adapter/outbound/persistense/mapper/MemberPersistenceMapper.java @@ -13,18 +13,17 @@ public interface MemberPersistenceMapper { @Mapping(source = "nickname", target = "memberInfo.nickname") @Mapping(source = "role", target = "memberInfo.role") @Mapping(source = "departmentRole", target = "memberInfo.departmentRole") - @Mapping(source = "department", target = "memberInfo.department") // Department 변환 + @Mapping(source = "department", target = "memberInfo.department") @Mapping(source = "admin", target = "admin") - Member toDomain(final MemberEntity entity); - + Member toDomain(MemberEntity entity); @Mapping(source = "memberInfo.name", target = "name") @Mapping(source = "memberInfo.email", target = "email") @Mapping(source = "memberInfo.nickname", target = "nickname") @Mapping(source = "memberInfo.role", target = "role") @Mapping(source = "memberInfo.departmentRole", target = "departmentRole") - @Mapping(source = "memberInfo.department", target = "department") // Department 변환 - @Mapping(source = "admin", target = "admin") + @Mapping(source = "memberInfo.department", target = "department") + @Mapping(target = "admin", source = "admin") MemberEntity toEntity(Member member); default boolean mapIsReviewer(Member member) { diff --git a/src/main/java/clap/server/adapter/outbound/persistense/repository/member/MemberRepository.java b/src/main/java/clap/server/adapter/outbound/persistense/repository/member/MemberRepository.java index 99be21ab..5d345651 100644 --- a/src/main/java/clap/server/adapter/outbound/persistense/repository/member/MemberRepository.java +++ b/src/main/java/clap/server/adapter/outbound/persistense/repository/member/MemberRepository.java @@ -10,4 +10,6 @@ @Repository public interface MemberRepository extends JpaRepository { Optional findByStatusAndMemberId(MemberStatus memberStatus, Long memberId); + + Optional findByNickname(String nickname); } \ No newline at end of file diff --git a/src/main/java/clap/server/application/AuthService.java b/src/main/java/clap/server/application/AuthService.java deleted file mode 100644 index e33639f6..00000000 --- a/src/main/java/clap/server/application/AuthService.java +++ /dev/null @@ -1,10 +0,0 @@ -package clap.server.application; - -import clap.server.application.port.inbound.auth.AuthUsecase; -import clap.server.common.annotation.architecture.ApplicationService; -import lombok.RequiredArgsConstructor; - -@ApplicationService -@RequiredArgsConstructor -public class AuthService implements AuthUsecase { -} diff --git a/src/main/java/clap/server/application/ResetPasswordService.java b/src/main/java/clap/server/application/ResetPasswordService.java index 56739816..3f46d878 100644 --- a/src/main/java/clap/server/application/ResetPasswordService.java +++ b/src/main/java/clap/server/application/ResetPasswordService.java @@ -1,10 +1,36 @@ package clap.server.application; import clap.server.application.port.inbound.auth.ResetPasswordUsecase; +import clap.server.application.port.inbound.domain.MemberService; +import clap.server.application.port.outbound.member.CommandMemberPort; import clap.server.common.annotation.architecture.ApplicationService; +import clap.server.domain.model.member.Member; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.transaction.annotation.Transactional; @ApplicationService @RequiredArgsConstructor -public class ResetPasswordService implements ResetPasswordUsecase { +@Transactional +@Slf4j +class ResetPasswordService implements ResetPasswordUsecase { + private final MemberService memberService; + private final PasswordEncoder passwordEncoder; + private final CommandMemberPort commandMemberPort; + + @Override + public void resetPassword(Long memberId, String inputPassword) { + Member member = memberService.findActiveMember(memberId); + String encodedPassword = passwordEncoder.encode(inputPassword); + member.resetPassword(encodedPassword); + commandMemberPort.save(member); + } + + @Override + public void resetPasswordAndActivateMember(Long memberId, String password) { + Member member = memberService.findById(memberId); + member.resetPasswordAndActivateMember(passwordEncoder.encode(password)); + commandMemberPort.save(member); + } } diff --git a/src/main/java/clap/server/application/mapper/MemberMapper.java b/src/main/java/clap/server/application/mapper/MemberMapper.java index aeed7e4b..628be123 100644 --- a/src/main/java/clap/server/application/mapper/MemberMapper.java +++ b/src/main/java/clap/server/application/mapper/MemberMapper.java @@ -11,8 +11,6 @@ private MemberMapper() { public static Member toMember(MemberInfo memberInfo) { return Member.builder() .memberInfo(memberInfo) - .notificationEnabled(null) - .imageUrl(null) .build(); } } diff --git a/src/main/java/clap/server/application/mapper/response/AuthResponseMapper.java b/src/main/java/clap/server/application/mapper/response/AuthResponseMapper.java new file mode 100644 index 00000000..f5545efa --- /dev/null +++ b/src/main/java/clap/server/application/mapper/response/AuthResponseMapper.java @@ -0,0 +1,30 @@ +package clap.server.application.mapper.response; + +import clap.server.adapter.inbound.web.dto.auth.LoginResponse; +import clap.server.adapter.inbound.web.dto.auth.MemberInfoResponse; +import clap.server.domain.model.member.Member; + +public class AuthResponseMapper { + private AuthResponseMapper() { + throw new IllegalArgumentException(); + } + + public static LoginResponse toLoginResponse(final String accessToken, final String refreshToken, final Member member) { + return new LoginResponse( + accessToken, + refreshToken, + toMemberInfoResponse(member) + ); + } + + public static MemberInfoResponse toMemberInfoResponse(Member member) { + return new MemberInfoResponse( + member.getMemberId(), + member.getMemberInfo().getName(), + member.getMemberInfo().getNickname(), + member.getImageUrl(), + member.getMemberInfo().getRole(), + member.getStatus() + ); + } +} diff --git a/src/main/java/clap/server/application/port/inbound/auth/AuthUsecase.java b/src/main/java/clap/server/application/port/inbound/auth/AuthUsecase.java index a3d964a8..562a7391 100644 --- a/src/main/java/clap/server/application/port/inbound/auth/AuthUsecase.java +++ b/src/main/java/clap/server/application/port/inbound/auth/AuthUsecase.java @@ -1,5 +1,7 @@ package clap.server.application.port.inbound.auth; -public interface AuthUsecase { +import clap.server.adapter.inbound.web.dto.auth.LoginResponse; +public interface AuthUsecase { + LoginResponse login(String nickname, String password); } diff --git a/src/main/java/clap/server/application/port/inbound/auth/ResetPasswordUsecase.java b/src/main/java/clap/server/application/port/inbound/auth/ResetPasswordUsecase.java index cab557ea..a1ef7a92 100644 --- a/src/main/java/clap/server/application/port/inbound/auth/ResetPasswordUsecase.java +++ b/src/main/java/clap/server/application/port/inbound/auth/ResetPasswordUsecase.java @@ -1,4 +1,6 @@ package clap.server.application.port.inbound.auth; public interface ResetPasswordUsecase { + void resetPassword(Long memberId, String password); + void resetPasswordAndActivateMember(Long memberId, String password); } diff --git a/src/main/java/clap/server/application/port/outbound/auth/CommandRefreshTokenPort.java b/src/main/java/clap/server/application/port/outbound/auth/CommandRefreshTokenPort.java new file mode 100644 index 00000000..61ffe0a6 --- /dev/null +++ b/src/main/java/clap/server/application/port/outbound/auth/CommandRefreshTokenPort.java @@ -0,0 +1,8 @@ +package clap.server.application.port.outbound.auth; + +import clap.server.domain.model.auth.RefreshToken; + +public interface CommandRefreshTokenPort { + void save(RefreshToken refreshToken); + void delete(RefreshToken refreshToken); +} \ No newline at end of file diff --git a/src/main/java/clap/server/application/port/outbound/auth/JwtProvider.java b/src/main/java/clap/server/application/port/outbound/auth/JwtProvider.java new file mode 100644 index 00000000..ebe5a458 --- /dev/null +++ b/src/main/java/clap/server/application/port/outbound/auth/JwtProvider.java @@ -0,0 +1,27 @@ +package clap.server.application.port.outbound.auth; + +import clap.server.adapter.outbound.jwt.JwtClaims; +import clap.server.common.constants.AuthConstants; +import io.jsonwebtoken.Claims; +import org.springframework.util.StringUtils; + +import java.time.LocalDateTime; + +public interface JwtProvider { + default String resolveToken(String header) { + if (StringUtils.hasText(header) && header.startsWith(AuthConstants.TOKEN_PREFIX.getValue())) { + return header.substring(AuthConstants.TOKEN_PREFIX.getValue().length()); + } + return ""; + } + + String createToken(JwtClaims claims); + + JwtClaims parseJwtClaimsFromToken(String token); + + LocalDateTime getExpiredDate(String token); + + boolean isTokenExpired(String token); + + Claims getClaimsFromToken(String token); +} diff --git a/src/main/java/clap/server/application/port/outbound/auth/LoadRefreshTokenPort.java b/src/main/java/clap/server/application/port/outbound/auth/LoadRefreshTokenPort.java new file mode 100644 index 00000000..1fe8f5e9 --- /dev/null +++ b/src/main/java/clap/server/application/port/outbound/auth/LoadRefreshTokenPort.java @@ -0,0 +1,9 @@ +package clap.server.application.port.outbound.auth; + +import clap.server.domain.model.auth.RefreshToken; + +import java.util.Optional; + +public interface LoadRefreshTokenPort { + Optional findByMemberId(Long memberId); +} \ No newline at end of file diff --git a/src/main/java/clap/server/application/port/outbound/member/CommandMemberPort.java b/src/main/java/clap/server/application/port/outbound/member/CommandMemberPort.java index 027e4f61..5ad3903d 100644 --- a/src/main/java/clap/server/application/port/outbound/member/CommandMemberPort.java +++ b/src/main/java/clap/server/application/port/outbound/member/CommandMemberPort.java @@ -2,8 +2,6 @@ import clap.server.domain.model.member.Member; -import java.util.Optional; - public interface CommandMemberPort { void save(Member member); } diff --git a/src/main/java/clap/server/application/port/outbound/member/LoadMemberPort.java b/src/main/java/clap/server/application/port/outbound/member/LoadMemberPort.java index 40647bb7..5962d38a 100644 --- a/src/main/java/clap/server/application/port/outbound/member/LoadMemberPort.java +++ b/src/main/java/clap/server/application/port/outbound/member/LoadMemberPort.java @@ -8,4 +8,6 @@ public interface LoadMemberPort { Optional findById(Long id); Optional findActiveMemberById(Long id); + + Optional findByNickname(String nickname); } diff --git a/src/main/java/clap/server/application/RegisterMemberService.java b/src/main/java/clap/server/application/service/admin/RegisterMemberService.java similarity index 85% rename from src/main/java/clap/server/application/RegisterMemberService.java rename to src/main/java/clap/server/application/service/admin/RegisterMemberService.java index 650a5ce0..906cf06b 100644 --- a/src/main/java/clap/server/application/RegisterMemberService.java +++ b/src/main/java/clap/server/application/service/admin/RegisterMemberService.java @@ -1,18 +1,18 @@ -package clap.server.application; +package clap.server.application.service.admin; import clap.server.adapter.inbound.web.dto.admin.RegisterMemberRequest; -import clap.server.adapter.outbound.persistense.entity.member.constant.MemberRole; import clap.server.application.port.inbound.domain.MemberService; import clap.server.application.port.inbound.management.RegisterMemberUsecase; import clap.server.application.port.outbound.member.CommandMemberPort; import clap.server.application.port.outbound.member.LoadDepartmentPort; import clap.server.common.annotation.architecture.ApplicationService; -import clap.server.exception.ApplicationException; import clap.server.domain.model.member.Department; import clap.server.domain.model.member.Member; import clap.server.domain.model.member.MemberInfo; +import clap.server.exception.ApplicationException; import clap.server.exception.code.DepartmentErrorCode; import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.transaction.annotation.Transactional; import static clap.server.application.mapper.MemberInfoMapper.toMemberInfo; @@ -20,10 +20,11 @@ @ApplicationService @RequiredArgsConstructor -public class RegisterMemberService implements RegisterMemberUsecase { +class RegisterMemberService implements RegisterMemberUsecase { private final MemberService memberService; private final CommandMemberPort commandMemberPort; private final LoadDepartmentPort loadDepartmentPort; + private final PasswordEncoder passwordEncoder; @Override @Transactional @@ -31,7 +32,7 @@ public void registerMember(Long adminId, RegisterMemberRequest request) { Member admin = memberService.findActiveMember(adminId); Department department = loadDepartmentPort.findById(request.departmentId()).orElseThrow(()-> new ApplicationException(DepartmentErrorCode.DEPARTMENT_NOT_FOUND)); MemberInfo memberInfo = toMemberInfo(request.name(), request.email(), request.nickname(), request.isReviewer(), - department, MemberRole.ROLE_USER, request.departmentRole()); + department, request.role(), request.departmentRole()); Member member = toMember(memberInfo); member.register(admin); commandMemberPort.save(member); diff --git a/src/main/java/clap/server/application/service/auth/AuthService.java b/src/main/java/clap/server/application/service/auth/AuthService.java new file mode 100644 index 00000000..ee960017 --- /dev/null +++ b/src/main/java/clap/server/application/service/auth/AuthService.java @@ -0,0 +1,50 @@ +package clap.server.application.service.auth; + +import clap.server.adapter.inbound.web.dto.auth.LoginResponse; +import clap.server.adapter.outbound.persistense.entity.member.constant.MemberStatus; +import clap.server.application.mapper.response.AuthResponseMapper; +import clap.server.application.port.inbound.auth.AuthUsecase; +import clap.server.application.port.outbound.member.LoadMemberPort; +import clap.server.common.annotation.architecture.ApplicationService; +import clap.server.domain.model.auth.CustomJwts; +import clap.server.domain.model.member.Member; +import clap.server.exception.ApplicationException; +import clap.server.exception.code.MemberErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.transaction.annotation.Transactional; + +@ApplicationService +@RequiredArgsConstructor +class AuthService implements AuthUsecase { + private final LoadMemberPort loadMemberPort; + private final IssueTokenService issueTokenService; + private final PasswordEncoder passwordEncoder; + + @Override + @Transactional + public LoginResponse login(String nickname, String password) { + Member member = loadMemberPort.findByNickname(nickname).orElseThrow( + () -> new ApplicationException(MemberErrorCode.MEMBER_NOT_FOUND)); + validatePassword(password, member.getPassword()); + + if (member.getStatus().equals(MemberStatus.APPROVAL_REQUEST)) { + String temporaryToken = issueTokenService.createTemporaryToken(member); + return AuthResponseMapper.toLoginResponse( + temporaryToken, null, member + ); + } else { + CustomJwts jwtTokens = issueTokenService.createToken(member); + return AuthResponseMapper.toLoginResponse( + jwtTokens.accessToken(), jwtTokens.refreshToken(), member + ); + } + } + + + private void validatePassword(String inputPassword, String encodedPassword) { + if (!passwordEncoder.matches(inputPassword, encodedPassword)) { + throw new ApplicationException(MemberErrorCode.MEMBER_NOT_FOUND); + } + } +} diff --git a/src/main/java/clap/server/application/service/auth/IssueTokenService.java b/src/main/java/clap/server/application/service/auth/IssueTokenService.java new file mode 100644 index 00000000..42b9d348 --- /dev/null +++ b/src/main/java/clap/server/application/service/auth/IssueTokenService.java @@ -0,0 +1,80 @@ +package clap.server.application.service.auth; + +import clap.server.adapter.outbound.jwt.access.AccessTokenClaim; +import clap.server.adapter.outbound.jwt.access.temporary.TemporaryTokenClaim; +import clap.server.adapter.outbound.jwt.refresh.RefreshTokenClaim; +import clap.server.application.port.outbound.auth.CommandRefreshTokenPort; +import clap.server.application.port.outbound.auth.JwtProvider; +import clap.server.application.port.outbound.auth.LoadRefreshTokenPort; +import clap.server.domain.model.auth.CustomJwts; +import clap.server.domain.model.auth.RefreshToken; +import clap.server.domain.model.member.Member; +import clap.server.exception.AuthException; +import clap.server.exception.code.AuthErrorCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.time.LocalDateTime; + +@RequiredArgsConstructor +@Component +@Slf4j +class IssueTokenService { + private final JwtProvider accessTokenProvider; + private final JwtProvider refreshTokenProvider; + private final JwtProvider temporaryTokenProvider; + private final LoadRefreshTokenPort loadRefreshTokenPort; + private final CommandRefreshTokenPort commandRefreshTokenPort; + + public CustomJwts createToken(Member member) { + String accessToken = accessTokenProvider.createToken(AccessTokenClaim.of(member.getMemberId())); + String refreshToken = refreshTokenProvider.createToken(RefreshTokenClaim.of(member.getMemberId())); + + commandRefreshTokenPort.save( + RefreshToken.of( + member.getMemberId(), refreshToken, + toSeconds(refreshTokenProvider.getExpiredDate(refreshToken)) + ) + ); + + return CustomJwts.of(accessToken, refreshToken); + } + + public String createTemporaryToken(Member member) { + return temporaryTokenProvider.createToken(TemporaryTokenClaim.of(member.getMemberId())); + } + + private long toSeconds(LocalDateTime expiredDate) { + return Duration.between(LocalDateTime.now(), expiredDate).getSeconds(); + } + + public RefreshToken refresh( + Long memberId, + String oldRefreshToken, + String newRefreshToken + ) throws IllegalArgumentException, IllegalStateException { + RefreshToken refreshToken = loadRefreshTokenPort.findByMemberId(memberId).orElseThrow( + ()-> new AuthException(AuthErrorCode.REFRESH_TOKEN_NOT_FOUND) + ); + validateToken(oldRefreshToken, refreshToken); + + refreshToken.rotation(newRefreshToken); + commandRefreshTokenPort.save(refreshToken); + + return refreshToken; + } + + private void validateToken(String oldRefreshToken, RefreshToken refreshToken) { + if (isTakenAway(oldRefreshToken, refreshToken.getToken())) { + commandRefreshTokenPort.delete(refreshToken); + throw new AuthException(AuthErrorCode.REFRESH_TOKEN_MISMATCHED); + } + } + + private boolean isTakenAway(String requestRefreshToken, String expectedRefreshToken) { + return !requestRefreshToken.equals(expectedRefreshToken); + } + +} diff --git a/src/main/java/clap/server/common/annotation/jwt/AccessTokenStrategy.java b/src/main/java/clap/server/common/annotation/jwt/AccessTokenStrategy.java new file mode 100644 index 00000000..6de395f0 --- /dev/null +++ b/src/main/java/clap/server/common/annotation/jwt/AccessTokenStrategy.java @@ -0,0 +1,18 @@ +package clap.server.common.annotation.jwt; + +import org.springframework.beans.factory.annotation.Qualifier; + +import java.lang.annotation.*; + +@Target(value = { + ElementType.ANNOTATION_TYPE, + ElementType.FIELD, + ElementType.METHOD, + ElementType.PARAMETER, + ElementType.TYPE +}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Qualifier("accessTokenStrategy") +public @interface AccessTokenStrategy { +} diff --git a/src/main/java/clap/server/common/annotation/jwt/RefreshTokenStrategy.java b/src/main/java/clap/server/common/annotation/jwt/RefreshTokenStrategy.java new file mode 100644 index 00000000..14307e1e --- /dev/null +++ b/src/main/java/clap/server/common/annotation/jwt/RefreshTokenStrategy.java @@ -0,0 +1,18 @@ +package clap.server.common.annotation.jwt; + +import org.springframework.beans.factory.annotation.Qualifier; + +import java.lang.annotation.*; + +@Target(value = { + ElementType.ANNOTATION_TYPE, + ElementType.FIELD, + ElementType.METHOD, + ElementType.PARAMETER, + ElementType.TYPE +}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Qualifier("refreshTokenStrategy") +public @interface RefreshTokenStrategy { +} diff --git a/src/main/java/clap/server/common/annotation/jwt/TemporaryTokenStrategy.java b/src/main/java/clap/server/common/annotation/jwt/TemporaryTokenStrategy.java new file mode 100644 index 00000000..2fc6227a --- /dev/null +++ b/src/main/java/clap/server/common/annotation/jwt/TemporaryTokenStrategy.java @@ -0,0 +1,18 @@ +package clap.server.common.annotation.jwt; + +import org.springframework.beans.factory.annotation.Qualifier; + +import java.lang.annotation.*; + +@Target(value = { + ElementType.ANNOTATION_TYPE, + ElementType.FIELD, + ElementType.METHOD, + ElementType.PARAMETER, + ElementType.TYPE +}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Qualifier("temporaryTokenStrategy") +public @interface TemporaryTokenStrategy { +} diff --git a/src/main/java/clap/server/common/annotation/validation/password/PasswordValidator.java b/src/main/java/clap/server/common/annotation/validation/password/PasswordValidator.java new file mode 100644 index 00000000..03b82fd7 --- /dev/null +++ b/src/main/java/clap/server/common/annotation/validation/password/PasswordValidator.java @@ -0,0 +1,16 @@ +package clap.server.common.annotation.validation.password; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +import java.util.regex.Pattern; + +public class PasswordValidator implements ConstraintValidator { + + private static final String PASSWORD_REGEX = "^(?=.*[A-Z])(?=.*[a-z])(?=.*\\d)(?=.*[!@#$%^&*()_+={}\\[\\]:;\"'<>,.?/\\\\|]).{8,}$"; + + @Override + public boolean isValid(String password, ConstraintValidatorContext context) { + return password != null && Pattern.matches(PASSWORD_REGEX, password); + } +} diff --git a/src/main/java/clap/server/common/annotation/validation/password/ValidPassword.java b/src/main/java/clap/server/common/annotation/validation/password/ValidPassword.java new file mode 100644 index 00000000..8b8b436b --- /dev/null +++ b/src/main/java/clap/server/common/annotation/validation/password/ValidPassword.java @@ -0,0 +1,21 @@ +package clap.server.common.annotation.validation.password; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target({ ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER }) +@Retention(RetentionPolicy.RUNTIME) +@Constraint(validatedBy = PasswordValidator.class) +public @interface ValidPassword { + + String message() default "대문자, 소문자, 숫자, 특수문자를 포함하며 8자 이상이어야 합니다."; + + Class[] groups() default {}; + + Class[] payload() default {}; +} diff --git a/src/main/java/clap/server/common/constants/AuthConstants.java b/src/main/java/clap/server/common/constants/AuthConstants.java new file mode 100644 index 00000000..1fbcbb3e --- /dev/null +++ b/src/main/java/clap/server/common/constants/AuthConstants.java @@ -0,0 +1,14 @@ +package clap.server.common.constants; + +import lombok.Getter; + +@Getter +public enum AuthConstants { + AUTHORIZATION("Authorization"), TOKEN_PREFIX("Bearer "); + + private final String value; + + AuthConstants(String value) { + this.value = value; + } +} diff --git a/src/main/java/clap/server/common/properties/PasswordPolicyProperties.java b/src/main/java/clap/server/common/properties/PasswordPolicyProperties.java new file mode 100644 index 00000000..f25b5bf8 --- /dev/null +++ b/src/main/java/clap/server/common/properties/PasswordPolicyProperties.java @@ -0,0 +1,15 @@ +package clap.server.common.properties; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Getter +@Setter +@Component +@ConfigurationProperties(prefix = "password.policy") +public class PasswordPolicyProperties { + private String characters; + private int length; +} diff --git a/src/main/java/clap/server/common/utils/DateUtil.java b/src/main/java/clap/server/common/utils/DateUtil.java new file mode 100644 index 00000000..772474e1 --- /dev/null +++ b/src/main/java/clap/server/common/utils/DateUtil.java @@ -0,0 +1,26 @@ +package clap.server.common.utils; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Date; + +public class DateUtil { + + private DateUtil() { + throw new IllegalStateException("Utility class"); + } + + public static Date toDate(LocalDate localDate) { + return Date.from(localDate.atStartOfDay().atZone(ZoneId.systemDefault()).toInstant()); + } + + public static Date toDate(LocalDateTime localDateTime) { + return Date.from(localDateTime.atZone(ZoneId.systemDefault()).toInstant()); + } + + public static LocalDateTime toLocalDateTime(Date date) { + return Instant.ofEpochMilli(date.getTime()).atZone(ZoneId.systemDefault()).toLocalDateTime(); + } +} diff --git a/src/main/java/clap/server/common/utils/InitialPasswordGenerator.java b/src/main/java/clap/server/common/utils/InitialPasswordGenerator.java new file mode 100644 index 00000000..550539e2 --- /dev/null +++ b/src/main/java/clap/server/common/utils/InitialPasswordGenerator.java @@ -0,0 +1,37 @@ +package clap.server.common.utils; + +import clap.server.common.properties.PasswordPolicyProperties; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.security.SecureRandom; + +@Component +@RequiredArgsConstructor +public class InitialPasswordGenerator { + + private final PasswordPolicyProperties properties; + + public String generateRandomPassword() { + return generateRandomPassword(properties.getLength()); + } + + public String generateRandomPassword(int length) { + if (length <= 0) { + throw new IllegalArgumentException("Password length must be greater than 0"); + } + + SecureRandom secureRandom = new SecureRandom(); + StringBuilder password = new StringBuilder(length); + + String characters = properties.getCharacters(); + + for (int i = 0; i < length; i++) { + int randomIndex = secureRandom.nextInt(properties.getLength()); + password.append(characters.charAt(randomIndex)); + } + + return password.toString(); + } +} diff --git a/src/main/java/clap/server/config/security/MethodSecurityConfig.java b/src/main/java/clap/server/config/security/MethodSecurityConfig.java new file mode 100644 index 00000000..2877a293 --- /dev/null +++ b/src/main/java/clap/server/config/security/MethodSecurityConfig.java @@ -0,0 +1,14 @@ +package clap.server.config.security; + +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; + +@Configuration +@EnableWebSecurity +@EnableGlobalMethodSecurity( + prePostEnabled = true, + securedEnabled = true +) +public class MethodSecurityConfig { +} diff --git a/src/main/java/clap/server/config/security/SecurityAdapterConfig.java b/src/main/java/clap/server/config/security/SecurityAdapterConfig.java new file mode 100644 index 00000000..70f14aa7 --- /dev/null +++ b/src/main/java/clap/server/config/security/SecurityAdapterConfig.java @@ -0,0 +1,26 @@ +package clap.server.config.security; + +import clap.server.adapter.inbound.security.filter.JwtAuthenticationFilter; +import clap.server.adapter.inbound.security.filter.JwtExceptionFilter; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; +import org.springframework.security.config.annotation.SecurityConfigurerAdapter; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.web.DefaultSecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@Configuration +@RequiredArgsConstructor +public class SecurityAdapterConfig extends SecurityConfigurerAdapter { + private final DaoAuthenticationProvider daoAuthenticationProvider; + private final JwtAuthenticationFilter jwtAuthenticationFilter; + private final JwtExceptionFilter jwtExceptionFilter; + + @Override + public void configure(HttpSecurity builder) throws Exception { + builder.authenticationProvider(daoAuthenticationProvider); + builder.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + builder.addFilterBefore(jwtExceptionFilter, JwtAuthenticationFilter.class); + } +} diff --git a/src/main/java/clap/server/config/security/SecurityAuthConfig.java b/src/main/java/clap/server/config/security/SecurityAuthConfig.java new file mode 100644 index 00000000..f3a09d2a --- /dev/null +++ b/src/main/java/clap/server/config/security/SecurityAuthConfig.java @@ -0,0 +1,50 @@ +package clap.server.config.security; + +import clap.server.adapter.inbound.security.handler.JwtAccessDeniedHandler; +import clap.server.adapter.inbound.security.handler.JwtAuthenticationEntryPoint; +import clap.server.adapter.inbound.security.SecurityUserDetailsService; +import lombok.AccessLevel; +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.authentication.dao.DaoAuthenticationProvider; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.security.web.access.AccessDeniedHandler; + +@Configuration +@RequiredArgsConstructor(access = AccessLevel.PROTECTED) +public class SecurityAuthConfig { + private final SecurityUserDetailsService userDetailsService; + + @Bean + public PasswordEncoder bCryptPasswordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public AccessDeniedHandler accessDeniedHandler() { + return new JwtAccessDeniedHandler(); + } + + @Bean + public AuthenticationEntryPoint authenticationEntryPoint() { + return new JwtAuthenticationEntryPoint(); + } + + @Bean + public DaoAuthenticationProvider daoAuthenticationProvider() { + DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider(); + daoAuthenticationProvider.setUserDetailsService(userDetailsService); + daoAuthenticationProvider.setPasswordEncoder(bCryptPasswordEncoder()); + return daoAuthenticationProvider; + } + + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception { + return configuration.getAuthenticationManager(); + } +} diff --git a/src/main/java/clap/server/config/security/SecurityConfig.java b/src/main/java/clap/server/config/security/SecurityConfig.java new file mode 100644 index 00000000..4ad8b226 --- /dev/null +++ b/src/main/java/clap/server/config/security/SecurityConfig.java @@ -0,0 +1,90 @@ +package clap.server.config.security; + +import lombok.RequiredArgsConstructor; +import org.springframework.boot.autoconfigure.security.ConditionalOnDefaultWebSecurity; +import org.springframework.boot.autoconfigure.security.SecurityProperties; +import org.springframework.boot.autoconfigure.security.servlet.PathRequest; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.core.annotation.Order; +import org.springframework.http.HttpMethod; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.AbstractRequestMatcherRegistry; +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.annotation.web.configurers.AuthorizeHttpRequestsConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.web.cors.CorsConfigurationSource; + +import static clap.server.config.security.WebSecurityUrl.*; + +@Configuration +@EnableWebSecurity +@ConditionalOnDefaultWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + private final SecurityAdapterConfig securityAdapterConfig; + private final CorsConfigurationSource corsConfigurationSource; + private final AccessDeniedHandler accessDeniedHandler; + private final AuthenticationEntryPoint authenticationEntryPoint; + + @Bean + @Profile({"local", "dev"}) + @Order(SecurityProperties.BASIC_AUTH_ORDER) + public SecurityFilterChain filterChainForDev(HttpSecurity http) throws Exception { + return defaultSecurity(http) + .cors(cors -> cors.configurationSource(corsConfigurationSource)) + .authorizeHttpRequests( + auth -> + defaultAuthorizeHttpRequest(auth) + .requestMatchers(SWAGGER_ENDPOINTS).permitAll() + .anyRequest().authenticated() + ).build(); + } + + @Bean + @Profile({"prod"}) + @Order(SecurityProperties.BASIC_AUTH_ORDER) + public SecurityFilterChain filterChainForProd(HttpSecurity http) throws Exception { + return defaultSecurity(http) + .cors(cors -> cors.configurationSource(corsConfigurationSource)) + .authorizeHttpRequests(auth -> defaultAuthorizeHttpRequest(auth).anyRequest().authenticated() + ).build(); + } + + private HttpSecurity defaultSecurity(HttpSecurity http) throws Exception { + return http + .httpBasic(AbstractHttpConfigurer::disable) + .csrf(AbstractHttpConfigurer::disable) + .sessionManagement( + sessionManagement -> sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS) + ) + .formLogin(AbstractHttpConfigurer::disable) + .logout(AbstractHttpConfigurer::disable) + .with(securityAdapterConfig, Customizer.withDefaults()) + .exceptionHandling( + exception -> exception + .accessDeniedHandler(accessDeniedHandler) + .authenticationEntryPoint(authenticationEntryPoint) + ); + } + + private AbstractRequestMatcherRegistry.AuthorizedUrl> defaultAuthorizeHttpRequest( + AuthorizeHttpRequestsConfigurer.AuthorizationManagerRequestMatcherRegistry auth + ) { + return auth + .requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll() + .requestMatchers(HttpMethod.OPTIONS, "*").permitAll() + .requestMatchers(HttpMethod.GET, READ_ONLY_PUBLIC_ENDPOINTS).permitAll() + .requestMatchers(HEALTH_CHECK_ENDPOINT).permitAll() + .requestMatchers(REISSUANCE_ENDPOINTS).permitAll() + .requestMatchers(AUTHENTICATED_ENDPOINTS).authenticated() + .requestMatchers(ANONYMOUS_ENDPOINTS).permitAll(); + } + +} diff --git a/src/main/java/clap/server/config/security/SecurityFilterConfig.java b/src/main/java/clap/server/config/security/SecurityFilterConfig.java new file mode 100644 index 00000000..2bd00ea0 --- /dev/null +++ b/src/main/java/clap/server/config/security/SecurityFilterConfig.java @@ -0,0 +1,30 @@ +package clap.server.config.security; + +import clap.server.application.port.outbound.auth.JwtProvider; +import clap.server.adapter.inbound.security.filter.JwtAuthenticationFilter; +import clap.server.adapter.inbound.security.filter.JwtExceptionFilter; +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.web.access.AccessDeniedHandler; + +@Configuration +@RequiredArgsConstructor(access = AccessLevel.PROTECTED) +public class SecurityFilterConfig { + private final UserDetailsService securityUserDetails; + private final JwtProvider accessTokenProvider; + private final JwtProvider temporaryTokenProvider; + private final AccessDeniedHandler accessDeniedHandler; + + @Bean + public JwtExceptionFilter jwtExceptionFilter() { + return new JwtExceptionFilter(); + } + + @Bean + public JwtAuthenticationFilter jwtAuthenticationFilter() { + return new JwtAuthenticationFilter(securityUserDetails, accessTokenProvider, temporaryTokenProvider, accessDeniedHandler); + } +} diff --git a/src/main/java/clap/server/config/security/WebSecurityUrl.java b/src/main/java/clap/server/config/security/WebSecurityUrl.java new file mode 100644 index 00000000..3613c436 --- /dev/null +++ b/src/main/java/clap/server/config/security/WebSecurityUrl.java @@ -0,0 +1,17 @@ +package clap.server.config.security; + +public class WebSecurityUrl { + private WebSecurityUrl() { + throw new IllegalStateException("Utility class"); + } + + protected static final String [] HEALTH_CHECK_ENDPOINT = {"/health"}; + protected static final String[] READ_ONLY_PUBLIC_ENDPOINTS = {"/favicon.ico"}; + protected static final String[] AUTHENTICATED_ENDPOINTS = {}; + protected static final String[] ANONYMOUS_ENDPOINTS = {"/api/auths/login"}; + protected static final String[] SWAGGER_ENDPOINTS = { + "/swagger/api-docs/**", "/swagger/v3/api-docs/**", + "/swagger-ui/**", "/swagger" + }; + protected static final String[] REISSUANCE_ENDPOINTS = {"/api/auths/reissuance"}; +} diff --git a/src/main/java/clap/server/config/swagger/SwaggerConfig.java b/src/main/java/clap/server/config/swagger/SwaggerConfig.java index 9d49d427..9e3f1146 100644 --- a/src/main/java/clap/server/config/swagger/SwaggerConfig.java +++ b/src/main/java/clap/server/config/swagger/SwaggerConfig.java @@ -1,48 +1,62 @@ package clap.server.config.swagger; +import io.swagger.v3.oas.models.Components; import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; import io.swagger.v3.oas.models.servers.Server; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.Profile; +import java.util.Collections; import java.util.List; +import static clap.server.common.constants.AuthConstants.AUTHORIZATION; + @Configuration public class SwaggerConfig { + private static final String API_NAME = "TaskFlow API"; + private static final String API_VERSION = "1.0.0"; + @Value("${swagger.server.url}") private String serverUrl; @Bean - @Profile("local") - public OpenAPI localOpenAPI() { - return createOpenAPI(getLocalServer()); + public OpenAPI getOpenAPI() { + return new OpenAPI() + .components(getComponents()) + .servers(List.of(getServer())) + .security(getSecurity()) + .info(getInfo()); } - @Bean - @Profile("dev") - public OpenAPI devOpenAPI() { - return createOpenAPI(getDevServer()); + private Info getInfo() { + return new Info() + .title(API_NAME) + .version(API_VERSION); } - private OpenAPI createOpenAPI(Server server) { - return new OpenAPI() - .servers(List.of(server)) - .info(new Info().title("TaskFlow API").version("1.0")); + private static List getSecurity() { + SecurityRequirement securityRequirement = new SecurityRequirement() + .addList(AUTHORIZATION.getValue()); + + return Collections.singletonList(securityRequirement); } - private Server getLocalServer() { + private Server getServer() { return new Server() - .url(serverUrl) - .description("Local Server"); + .url(serverUrl); } - private Server getDevServer() { - return new Server() - .url(serverUrl) - .description("Development Server"); + private static Components getComponents() { + SecurityScheme securityScheme = new SecurityScheme() + .type(SecurityScheme.Type.HTTP).scheme("bearer").bearerFormat("JWT").name(AUTHORIZATION.getValue()) + .in(SecurityScheme.In.HEADER).name(AUTHORIZATION.getValue()); + + return new Components() + .addSecuritySchemes(AUTHORIZATION.getValue(), securityScheme); } } \ No newline at end of file diff --git a/src/main/java/clap/server/domain/model/auth/CustomJwts.java b/src/main/java/clap/server/domain/model/auth/CustomJwts.java new file mode 100644 index 00000000..e8d1496d --- /dev/null +++ b/src/main/java/clap/server/domain/model/auth/CustomJwts.java @@ -0,0 +1,10 @@ +package clap.server.domain.model.auth; + +public record CustomJwts( + String accessToken, + String refreshToken +) { + public static CustomJwts of(String accessToken, String refreshToken) { + return new CustomJwts(accessToken, refreshToken); + } +} diff --git a/src/main/java/clap/server/domain/model/auth/RefreshToken.java b/src/main/java/clap/server/domain/model/auth/RefreshToken.java new file mode 100644 index 00000000..58cb6a43 --- /dev/null +++ b/src/main/java/clap/server/domain/model/auth/RefreshToken.java @@ -0,0 +1,32 @@ +package clap.server.domain.model.auth; + +import lombok.*; +import lombok.experimental.SuperBuilder; + +@Getter +@SuperBuilder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class RefreshToken { + private Long memberId; + private String token; + private long ttl; + + @Builder + private RefreshToken(Long memberId, String token, long ttl) { + this.memberId = memberId; + this.token = token; + this.ttl = ttl; + } + + public static RefreshToken of(Long memberId, String token, long ttl) { + return RefreshToken.builder() + .memberId(memberId) + .token(token) + .ttl(ttl) + .build(); + } + + public void rotation(String token) { + this.token = token; + } +} diff --git a/src/main/java/clap/server/domain/model/member/Member.java b/src/main/java/clap/server/domain/model/member/Member.java index dde59d7e..1e9b7ce8 100644 --- a/src/main/java/clap/server/domain/model/member/Member.java +++ b/src/main/java/clap/server/domain/model/member/Member.java @@ -19,10 +19,11 @@ public class Member extends BaseTime { private String password; @Builder - public Member(MemberInfo memberInfo, Boolean notificationEnabled, String imageUrl, + public Member(MemberInfo memberInfo, Boolean notificationEnabled, Member admin, String imageUrl, MemberStatus status, String password) { this.memberInfo = memberInfo; this.notificationEnabled = notificationEnabled; + this.admin = admin; this.imageUrl = imageUrl; this.status = status; this.password = password; @@ -33,6 +34,15 @@ public void register(Member admin) { this.notificationEnabled = null; this.imageUrl = null; this.status = MemberStatus.PENDING; - this.password = ""; + this.password = null; + } + + public void resetPassword(String newEncodedPassword) { + this.password = newEncodedPassword; + } + + public void resetPasswordAndActivateMember(String newEncodedPassword) { + this.password = newEncodedPassword; + this.status = MemberStatus.ACTIVE; } } diff --git a/src/main/java/clap/server/exception/AuthException.java b/src/main/java/clap/server/exception/AuthException.java new file mode 100644 index 00000000..3a38a849 --- /dev/null +++ b/src/main/java/clap/server/exception/AuthException.java @@ -0,0 +1,13 @@ +package clap.server.exception; + +import clap.server.exception.code.BaseErrorCode; + +public class AuthException extends BaseException { + public AuthException(BaseErrorCode code) { + super(code); + } + + public BaseErrorCode getErrorCode() { + return (BaseErrorCode)super.getCode(); + } +} diff --git a/src/main/java/clap/server/exception/ExceptionAdvice.java b/src/main/java/clap/server/exception/ExceptionAdvice.java index 717cba68..d36b3e2a 100644 --- a/src/main/java/clap/server/exception/ExceptionAdvice.java +++ b/src/main/java/clap/server/exception/ExceptionAdvice.java @@ -1,5 +1,6 @@ package clap.server.exception; +import clap.server.exception.code.AuthErrorCode; import clap.server.exception.code.BaseErrorCode; import clap.server.exception.code.CommonErrorCode; import jakarta.servlet.http.HttpServletRequest; @@ -10,6 +11,7 @@ import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatusCode; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.AccessDeniedException; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @@ -156,4 +158,16 @@ private ResponseEntity handleExceptionInternalConstraint( request ); } + + @ExceptionHandler(AccessDeniedException.class) + public ResponseEntity handleAccessDeniedException(AccessDeniedException e, WebRequest request) { + return handleExceptionInternalFalse( + e, + AuthErrorCode.FORBIDDEN, + HttpHeaders.EMPTY, + HttpStatus.FORBIDDEN, + request, + AuthErrorCode.FORBIDDEN.getMessage() + ); + } } diff --git a/src/main/java/clap/server/exception/JwtException.java b/src/main/java/clap/server/exception/JwtException.java new file mode 100644 index 00000000..53b5f313 --- /dev/null +++ b/src/main/java/clap/server/exception/JwtException.java @@ -0,0 +1,13 @@ +package clap.server.exception; + +import clap.server.exception.code.BaseErrorCode; + +public class JwtException extends BaseException { + public JwtException(BaseErrorCode code) { + super(code); + } + + public BaseErrorCode getErrorCode() { + return (BaseErrorCode)super.getCode(); + } +} \ No newline at end of file diff --git a/src/main/java/clap/server/exception/code/AuthErrorCode.java b/src/main/java/clap/server/exception/code/AuthErrorCode.java new file mode 100644 index 00000000..36a9e9c4 --- /dev/null +++ b/src/main/java/clap/server/exception/code/AuthErrorCode.java @@ -0,0 +1,31 @@ +package clap.server.exception.code; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum AuthErrorCode implements BaseErrorCode { + UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "AUTH_001", "인증 과정에서 오류가 발생하였습니다."), + FORBIDDEN(HttpStatus.FORBIDDEN, "AUTH_002", "접근이 거부되었습니다"), + EMPTY_ACCESS_KEY(HttpStatus.FORBIDDEN, "AUTH_003", "AccessToken 이 비어있습니다."), + LOGOUT_ERROR(HttpStatus.FORBIDDEN, "AUTH_004", "로그 아웃된 사용자입니다."), + EXPIRED_TOKEN(HttpStatus.FORBIDDEN, "AUTH_005", "사용기간이 만료된 토큰입니다."), + TAKEN_AWAY_TOKEN(HttpStatus.FORBIDDEN, "AUTH_006", "탈취당한 토큰입니다. 다시 로그인 해주세요."), + WITHOUT_OWNER_REFRESH_TOKEN(HttpStatus.FORBIDDEN, "AUTH_007", "소유자가 아닌 RefreshToken 입니다."), + EXPIRATION_REFRESH_TOKEN(HttpStatus.FORBIDDEN, "AUTH_008", "RefreshToken 이 만료되었습니다."), + MALFORMED_TOKEN(HttpStatus.UNAUTHORIZED, "AUTH_009", "비정상적인 토큰입니다."), + TAMPERED_TOKEN(HttpStatus.UNAUTHORIZED, "AUTH_010", "서명이 조작된 토큰입니다."), + UNSUPPORTED_JWT_TOKEN(HttpStatus.UNAUTHORIZED, "AUTH_011", "지원하지 않는 토큰입니다."), + FORBIDDEN_ACCESS_TOKEN(HttpStatus.FORBIDDEN, "AUTH_012","해당 토큰에는 엑세스 권한이 없습니다."), + INVALID_TOKEN(HttpStatus.UNAUTHORIZED,"AUTH_013", "유효하지 않은 토큰입니다."), + REFRESH_TOKEN_NOT_FOUND(HttpStatus.UNAUTHORIZED, "AUTH_014", "리프레시 토큰을 찾을 수 없습니다."), + REFRESH_TOKEN_MISMATCHED(HttpStatus.UNAUTHORIZED, "AUTH_014", "리프레시 토큰이 일치하지 않습니다"); + + private final HttpStatus httpStatus; + private final String customCode; + private final String message; +} + + diff --git a/src/main/java/clap/server/exception/code/CommonErrorCode.java b/src/main/java/clap/server/exception/code/CommonErrorCode.java index d56e904d..f2c7a67c 100644 --- a/src/main/java/clap/server/exception/code/CommonErrorCode.java +++ b/src/main/java/clap/server/exception/code/CommonErrorCode.java @@ -13,8 +13,6 @@ public enum CommonErrorCode implements BaseErrorCode { */ BAD_REQUEST(HttpStatus.BAD_REQUEST, "COMMON_001", "잘못된 요청입니다."), METHOD_ARGUMENT_NOT_VALID(HttpStatus.BAD_REQUEST, "COMMON_002", "올바르지 않은 요청입니다."), - UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "COMMON_003", "인증 과정에서 오류가 발생하였습니다."), - FORBIDDEN(HttpStatus.FORBIDDEN, "COMMON_004", "금지된 요청입니다."), METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "COMMON_005", "지원하지 않은 Http Method 입니다."), INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "COMMON_006", "서버 에러가 발생했습니다."), ; diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 18de6d00..d89f2b6d 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -6,6 +6,7 @@ spring: - mysql.yml - swagger.yml - redis.yml + - auth.yml - optional:classpath:env.properties application: name: taskflow @@ -25,15 +26,18 @@ server: logging: level: root: INFO + taskflow.clap.server: ERROR org: springframework: DEBUG - taskflow.clap.server: DEBUG --- spring.config.activate.on-profile: local logging: level: root: INFO + taskflow.clap.server: ERROR + org: + springframework: DEBUG --- spring.config.activate.on-profile: dev diff --git a/src/main/resources/auth.yml b/src/main/resources/auth.yml new file mode 100644 index 00000000..785499d7 --- /dev/null +++ b/src/main/resources/auth.yml @@ -0,0 +1,23 @@ +jwt: + secret-key: + access-token: ${JWT_ACCESS_SECRET_KEY:exampleSecretKeyForTFSystemAccessSecretKeyTestForPadding} + temporary-token: ${JWT_TEMPORARY_SECRET_KEY:exampleSecretKeyForTFSystemTemporarySecretKeyTestForPadding} + refresh-token: ${JWT_REFRESH_SECRET_KEY:exampleSecretKeyTFSystemRefreshSecretKeyTestForPadding} + expiration-time: + access-token: ${JWT_ACCESS_EXPIRATION_TIME:43200000} # 1000 * 60 * 60 * 12 = 43200000 (12 hours) + temporary-token: ${JWT_TEMPORARY_EXPIRATION_TIME:300000} + refresh-token: ${JWT_REFRESH_EXPIRATION_TIME:1209600000} # 1000 * 60 * 60 * 24 * 14 = 1209600000 (14 days) + +password: + policy: + length: ${INITIAL_PASSWORD_LENGTH:12} + characters: ${INITIAL_PASSWORD_CHARACTERS:ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()_+} + +--- +spring.config.activate.on-profile: local + +--- +spring.config.activate.on-profile: dev + +--- +spring.config.activate.on-profile: prod diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index 5e5c5c04..a0c7628e 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -16,4 +16,20 @@ spring: swagger: server: - url: http://localhost:8080 \ No newline at end of file + url: http://localhost:8080 + + +jwt: + secret-key: + access-token: exampleSecretKeyForTFSystemAccessSecretKeyTestForPadding + temporary-token: exampleSecretKeyForTFSystemTemporarySecretKeyTestForPadding + refresh-token: exampleSecretKeyTFSystemRefreshSecretKeyTestForPadding + expiration-time: + access-token: 43200000 # 1000 * 60 * 60 * 12 = 43200000 (12 hours) + temporary-token: 300000 + refresh-token: 1209600000 # 1000 * 60 * 60 * 24 * 14 = 1209600000 (14 days) + +password: + policy: + length: 12 + characters: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()_+"