diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index 64f4159..ac650a6 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -47,7 +47,7 @@ jobs: - name: 🐋 Docker Run run: docker-compose -f docker-compose.test.yml up -d - name: ⌛ Wait for Application - run: sleep 30 + run: sleep 120 - name: 🧪 Test Application run: | RESPONSE=$(curl -s "http://127.0.0.1:8080${{ secrets.HEALTH_CHECK_PATH }}") diff --git a/src/main/java/com/ampersand/groom/domain/auth/application/port/AuthPort.java b/src/main/java/com/ampersand/groom/domain/auth/application/port/AuthPort.java new file mode 100644 index 0000000..e7e7d6f --- /dev/null +++ b/src/main/java/com/ampersand/groom/domain/auth/application/port/AuthPort.java @@ -0,0 +1,12 @@ +package com.ampersand.groom.domain.auth.application.port; + +import com.ampersand.groom.domain.member.persistence.entity.MemberJpaEntity; + +import java.util.Optional; + +public interface AuthPort { + + Optional findMembersByCriteria(String email); + + void save(MemberJpaEntity member); +} diff --git a/src/main/java/com/ampersand/groom/domain/auth/application/service/AuthService.java b/src/main/java/com/ampersand/groom/domain/auth/application/service/AuthService.java new file mode 100644 index 0000000..0505447 --- /dev/null +++ b/src/main/java/com/ampersand/groom/domain/auth/application/service/AuthService.java @@ -0,0 +1,100 @@ +package com.ampersand.groom.domain.auth.application.service; + +import com.ampersand.groom.domain.auth.application.port.AuthPort; +import com.ampersand.groom.domain.auth.expection.*; +import com.ampersand.groom.domain.auth.domain.JwtToken; +import com.ampersand.groom.domain.auth.presentation.data.request.SignupRequest; +import com.ampersand.groom.domain.member.persistence.entity.MemberJpaEntity; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +import java.time.Instant; + +@Service +@RequiredArgsConstructor +public class AuthService { + + private final JwtService jwtService; + private final PasswordEncoder passwordEncoder; + private final AuthPort authPort; + + @Value("${spring.jwt.token.access-expiration}") + private long accessTokenExpiration; + + @Value("${spring.jwt.token.refresh-expiration}") + private long refreshTokenExpiration; + + public JwtToken signIn(String email, String password) { + + MemberJpaEntity user = authPort.findMembersByCriteria(email) + .orElseThrow(()->new UserNotFoundException()); + + if(!user.getIsAvailable()) { + throw new UserForbiddenException(); + } + + if (!passwordEncoder.matches(password, user.getPassword())) { + throw new PasswordInvalidException(); + } + + String accessToken = jwtService.createAccessToken(email); + String refreshToken = jwtService.createRefreshToken(email); + + return JwtToken.builder() + .accessToken(accessToken) + .refreshToken(refreshToken) + .accessTokenExpiration(Instant.now().plusMillis(accessTokenExpiration)) + .refreshTokenExpiration(Instant.now().plusMillis(refreshTokenExpiration)) + .role(user.getRole()) + .build(); + } + + public JwtToken refreshToken(String refreshToken) { + if (refreshToken == null || refreshToken.isEmpty()) { + throw new RefreshTokenRequestFormatInvalidException(); + } + + String email = jwtService.getEmailFromToken(refreshToken); + boolean isTokenValid = jwtService.refreshToken(email, refreshToken); + if (!isTokenValid) { + throw new RefreshTokenExpiredOrInvalidException(); + } + + if (!jwtService.validateToken(refreshToken)) { + throw new RefreshTokenExpiredOrInvalidException(); + } + + MemberJpaEntity user = authPort.findMembersByCriteria(email) + .orElseThrow(()->new UserNotFoundException()); + + String newAccessToken = jwtService.createAccessToken(email); + String newRefreshToken = jwtService.createRefreshToken(email); + + return JwtToken.builder() + .accessToken(newAccessToken) + .refreshToken(newRefreshToken) + .accessTokenExpiration(Instant.now().plusMillis(accessTokenExpiration)) + .refreshTokenExpiration(Instant.now().plusMillis(refreshTokenExpiration)) + .role(user.getRole()) + .build(); + } + + public void signup(SignupRequest request) { + authPort.findMembersByCriteria(request.getEmail()) + .ifPresent(emailVerification -> { + throw new UserExistException(); + }); + + MemberJpaEntity newUser = MemberJpaEntity.builder() + .name(request.getName()) + .email(request.getEmail()) + .password(passwordEncoder.encode(request.getPassword())) + .generation(1) + .isAvailable(true) + .build(); + + authPort.save(newUser); + } +} diff --git a/src/main/java/com/ampersand/groom/domain/auth/application/service/CustomUserDetailsService.java b/src/main/java/com/ampersand/groom/domain/auth/application/service/CustomUserDetailsService.java new file mode 100644 index 0000000..5af4ad1 --- /dev/null +++ b/src/main/java/com/ampersand/groom/domain/auth/application/service/CustomUserDetailsService.java @@ -0,0 +1,29 @@ +package com.ampersand.groom.domain.auth.application.service; + +import com.ampersand.groom.domain.auth.application.port.AuthPort; +import com.ampersand.groom.domain.auth.expection.UserNotFoundException; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.stereotype.Service; + +import java.util.Collections; + +@Service +@RequiredArgsConstructor +public class CustomUserDetailsService implements UserDetailsService { + + private final AuthPort authPort; + + @Override + public UserDetails loadUserByUsername(String email) { + return authPort.findMembersByCriteria(email) + .map(member -> { + SimpleGrantedAuthority authority = new SimpleGrantedAuthority(member.getRole().name()); + return new User(member.getEmail(), member.getPassword(), Collections.singletonList(authority)); + }) + .orElseThrow(() -> new UserNotFoundException()); + } +} diff --git a/src/main/java/com/ampersand/groom/domain/auth/application/service/JwtService.java b/src/main/java/com/ampersand/groom/domain/auth/application/service/JwtService.java new file mode 100644 index 0000000..00e0880 --- /dev/null +++ b/src/main/java/com/ampersand/groom/domain/auth/application/service/JwtService.java @@ -0,0 +1,80 @@ +package com.ampersand.groom.domain.auth.application.service; + +import io.jsonwebtoken.*; +import jakarta.servlet.http.HttpServletRequest; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import javax.crypto.SecretKey; +import java.util.Date; +import java.util.concurrent.TimeUnit; + +@Service +@RequiredArgsConstructor +public class JwtService { + + private final RedisTemplate redisTemplate; + private final SecretKey secretKey; + @Getter + private final long accessTokenExpiration; + private final long refreshTokenExpiration; + + public String createAccessToken(String email) { + return generateToken(email, accessTokenExpiration); + } + + public String createRefreshToken(String email) { + String refreshToken = generateToken(email, refreshTokenExpiration); + + redisTemplate.opsForValue().set("refresh_token:" + email, refreshToken, refreshTokenExpiration, TimeUnit.SECONDS); + + return refreshToken; + } + + public boolean refreshToken(String email, String refreshToken) { + String storedToken = redisTemplate.opsForValue().get("refresh_token:" + email); + + if (storedToken == null || !storedToken.equals(refreshToken)) { + return false; + } + return true; + } + + private String generateToken(String subject, long expirationMs) { + Date now = new Date(); + return Jwts.builder() + .setSubject(subject) + .setIssuedAt(now) + .setExpiration(new Date(now.getTime() + expirationMs)) + .signWith(secretKey) + .compact(); + } + + public String getEmailFromToken(String token) { + return parseClaims(token).getSubject(); + } + + public boolean validateToken(String token) { + try { + parseClaims(token); + return true; + } catch (JwtException | IllegalArgumentException e) { + return false; + } + } + + private Claims parseClaims(String token) { + return Jwts.parser() + .setSigningKey(secretKey) + .build() + .parseClaimsJws(token) + .getBody(); + } + + public String resolveToken(HttpServletRequest request) { + String token = request.getHeader("Authorization"); + return (token != null && token.startsWith("Bearer ")) ? token.substring(7) : null; + } +} diff --git a/src/main/java/com/ampersand/groom/domain/auth/domain/JwtToken.java b/src/main/java/com/ampersand/groom/domain/auth/domain/JwtToken.java new file mode 100644 index 0000000..e1c849f --- /dev/null +++ b/src/main/java/com/ampersand/groom/domain/auth/domain/JwtToken.java @@ -0,0 +1,18 @@ +package com.ampersand.groom.domain.auth.domain; + +import com.ampersand.groom.domain.member.domain.constant.MemberRole; +import lombok.Builder; +import lombok.Getter; + +import java.time.Instant; + +@Getter +@Builder +public class JwtToken { + + private String accessToken; + private String refreshToken; + private Instant accessTokenExpiration; + private Instant refreshTokenExpiration; + private MemberRole role; +} diff --git a/src/main/java/com/ampersand/groom/domain/auth/expection/EmailOrPasswordEmptyException.java b/src/main/java/com/ampersand/groom/domain/auth/expection/EmailOrPasswordEmptyException.java new file mode 100644 index 0000000..1b1a10a --- /dev/null +++ b/src/main/java/com/ampersand/groom/domain/auth/expection/EmailOrPasswordEmptyException.java @@ -0,0 +1,10 @@ +package com.ampersand.groom.domain.auth.expection; + +import com.ampersand.groom.global.error.ErrorCode; +import com.ampersand.groom.global.error.exception.GroomException; + +public class EmailOrPasswordEmptyException extends GroomException { + public EmailOrPasswordEmptyException() { + super(ErrorCode.EMAIL_OR_PASSWORD_EMPTY); + } +} diff --git a/src/main/java/com/ampersand/groom/domain/auth/expection/PasswordInvalidException.java b/src/main/java/com/ampersand/groom/domain/auth/expection/PasswordInvalidException.java new file mode 100644 index 0000000..0245373 --- /dev/null +++ b/src/main/java/com/ampersand/groom/domain/auth/expection/PasswordInvalidException.java @@ -0,0 +1,10 @@ +package com.ampersand.groom.domain.auth.expection; + +import com.ampersand.groom.global.error.ErrorCode; +import com.ampersand.groom.global.error.exception.GroomException; + +public class PasswordInvalidException extends GroomException { + public PasswordInvalidException() { + super(ErrorCode.PASSWORD_INVALID); + } +} diff --git a/src/main/java/com/ampersand/groom/domain/auth/expection/RefreshTokenExpiredOrInvalidException.java b/src/main/java/com/ampersand/groom/domain/auth/expection/RefreshTokenExpiredOrInvalidException.java new file mode 100644 index 0000000..d89aca4 --- /dev/null +++ b/src/main/java/com/ampersand/groom/domain/auth/expection/RefreshTokenExpiredOrInvalidException.java @@ -0,0 +1,10 @@ +package com.ampersand.groom.domain.auth.expection; + +import com.ampersand.groom.global.error.ErrorCode; +import com.ampersand.groom.global.error.exception.GroomException; + +public class RefreshTokenExpiredOrInvalidException extends GroomException { + public RefreshTokenExpiredOrInvalidException() { + super(ErrorCode.REFRESH_TOKEN_EXPIRED_OR_INVALID); + } +} diff --git a/src/main/java/com/ampersand/groom/domain/auth/expection/RefreshTokenRequestFormatInvalidException.java b/src/main/java/com/ampersand/groom/domain/auth/expection/RefreshTokenRequestFormatInvalidException.java new file mode 100644 index 0000000..9f5869d --- /dev/null +++ b/src/main/java/com/ampersand/groom/domain/auth/expection/RefreshTokenRequestFormatInvalidException.java @@ -0,0 +1,10 @@ +package com.ampersand.groom.domain.auth.expection; + +import com.ampersand.groom.global.error.ErrorCode; +import com.ampersand.groom.global.error.exception.GroomException; + +public class RefreshTokenRequestFormatInvalidException extends GroomException { + public RefreshTokenRequestFormatInvalidException() { + super(ErrorCode.REFRESH_TOKEN_REQUEST_FORMAT_INVALID); + } +} diff --git a/src/main/java/com/ampersand/groom/domain/auth/expection/UserExistException.java b/src/main/java/com/ampersand/groom/domain/auth/expection/UserExistException.java new file mode 100644 index 0000000..dd6ddb5 --- /dev/null +++ b/src/main/java/com/ampersand/groom/domain/auth/expection/UserExistException.java @@ -0,0 +1,10 @@ +package com.ampersand.groom.domain.auth.expection; + +import com.ampersand.groom.global.error.ErrorCode; +import com.ampersand.groom.global.error.exception.GroomException; + +public class UserExistException extends GroomException { + public UserExistException() { + super(ErrorCode.USER_ALREADY_EXISTS); + } +} diff --git a/src/main/java/com/ampersand/groom/domain/auth/expection/UserForbiddenException.java b/src/main/java/com/ampersand/groom/domain/auth/expection/UserForbiddenException.java new file mode 100644 index 0000000..b0627be --- /dev/null +++ b/src/main/java/com/ampersand/groom/domain/auth/expection/UserForbiddenException.java @@ -0,0 +1,10 @@ +package com.ampersand.groom.domain.auth.expection; + +import com.ampersand.groom.global.error.ErrorCode; +import com.ampersand.groom.global.error.exception.GroomException; + +public class UserForbiddenException extends GroomException { + public UserForbiddenException() { + super(ErrorCode.USER_FORBIDDEN); + } +} diff --git a/src/main/java/com/ampersand/groom/domain/auth/expection/UserNotFoundException.java b/src/main/java/com/ampersand/groom/domain/auth/expection/UserNotFoundException.java new file mode 100644 index 0000000..da33493 --- /dev/null +++ b/src/main/java/com/ampersand/groom/domain/auth/expection/UserNotFoundException.java @@ -0,0 +1,10 @@ +package com.ampersand.groom.domain.auth.expection; + +import com.ampersand.groom.global.error.ErrorCode; +import com.ampersand.groom.global.error.exception.GroomException; + +public class UserNotFoundException extends GroomException { + public UserNotFoundException() { + super(ErrorCode.USER_NOT_FOUND); + } +} diff --git a/src/main/java/com/ampersand/groom/domain/auth/persistence/adapter/auth/AuthPortAdapter.java b/src/main/java/com/ampersand/groom/domain/auth/persistence/adapter/auth/AuthPortAdapter.java new file mode 100644 index 0000000..c4c3252 --- /dev/null +++ b/src/main/java/com/ampersand/groom/domain/auth/persistence/adapter/auth/AuthPortAdapter.java @@ -0,0 +1,28 @@ +package com.ampersand.groom.domain.auth.persistence.adapter.auth; + +import com.ampersand.groom.domain.auth.application.port.AuthPort; +import com.ampersand.groom.domain.member.persistence.entity.MemberJpaEntity; +import com.ampersand.groom.domain.member.persistence.repository.MemberJpaRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@Component +@RequiredArgsConstructor +public class AuthPortAdapter implements AuthPort { + + private final MemberJpaRepository memberJpaRepository; + + @Override + public Optional findMembersByCriteria(String email) { + return memberJpaRepository.findMembersByCriteria(null, null, null, email, null, null) + .stream().findFirst(); + } + + + @Override + public void save(MemberJpaEntity member) { + memberJpaRepository.save(member); + } +} diff --git a/src/main/java/com/ampersand/groom/domain/auth/presentation/controller/AuthController.java b/src/main/java/com/ampersand/groom/domain/auth/presentation/controller/AuthController.java index 0d82131..683dc64 100644 --- a/src/main/java/com/ampersand/groom/domain/auth/presentation/controller/AuthController.java +++ b/src/main/java/com/ampersand/groom/domain/auth/presentation/controller/AuthController.java @@ -1,17 +1,14 @@ package com.ampersand.groom.domain.auth.presentation.controller; +import com.ampersand.groom.domain.auth.application.service.AuthService; import com.ampersand.groom.domain.auth.application.usecase.EmailVerificationUseCase; -import com.ampersand.groom.domain.auth.presentation.dto.EmailRequest; -import com.ampersand.groom.domain.auth.presentation.dto.VerificationCodeRequest; +import com.ampersand.groom.domain.auth.domain.JwtToken; +import com.ampersand.groom.domain.auth.presentation.data.request.*; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; 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; -import org.springframework.web.bind.annotation.RestController; - +import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/auth") @@ -19,6 +16,25 @@ public class AuthController { private final EmailVerificationUseCase emailVerificationUseCase; + private final AuthService authService; + + @PostMapping("/signIn") + public ResponseEntity signIn(@RequestBody @Valid SignInRequest request) { + JwtToken jwtToken = authService.signIn(request.getEmail(), request.getPassword()); + return ResponseEntity.ok(jwtToken); + } + + @PostMapping("/signup") + public ResponseEntity signup(@RequestBody @Valid SignupRequest request) { + authService.signup(request); + return ResponseEntity.status(HttpStatus.CREATED).body("Signup successful"); + } + + @PatchMapping("/refresh") + public ResponseEntity refresh(@RequestBody @Valid RefreshRequest request) { + JwtToken jwtToken = authService.refreshToken(request.getRefreshToken()); + return ResponseEntity.ok(jwtToken); + } @PostMapping("/verify-email") public ResponseEntity verifyEmail(@RequestBody @Valid VerificationCodeRequest request) { @@ -27,14 +43,14 @@ public ResponseEntity verifyEmail(@RequestBody @Valid VerificationCodeRequest } @PostMapping("/signup/email") - public ResponseEntity signup(@RequestBody @Valid EmailRequest request) { + public ResponseEntity sendSignupVerificationEmail(@RequestBody @Valid EmailRequest request) { emailVerificationUseCase.executeSendSignupVerificationEmail(request.getEmail()); return ResponseEntity.status(HttpStatus.RESET_CONTENT).body("Verification email sent"); } @PostMapping("/password-change/email") - public ResponseEntity refresh(@RequestBody @Valid EmailRequest request) { + public ResponseEntity sendPasswordResetEmail(@RequestBody @Valid EmailRequest request) { emailVerificationUseCase.executeSendPasswordResetEmail(request.getEmail()); return ResponseEntity.status(HttpStatus.RESET_CONTENT).body("Verification email sent"); } -} \ No newline at end of file +} diff --git a/src/main/java/com/ampersand/groom/domain/auth/presentation/dto/EmailRequest.java b/src/main/java/com/ampersand/groom/domain/auth/presentation/data/request/EmailRequest.java similarity index 79% rename from src/main/java/com/ampersand/groom/domain/auth/presentation/dto/EmailRequest.java rename to src/main/java/com/ampersand/groom/domain/auth/presentation/data/request/EmailRequest.java index eaaf4e7..36cd765 100644 --- a/src/main/java/com/ampersand/groom/domain/auth/presentation/dto/EmailRequest.java +++ b/src/main/java/com/ampersand/groom/domain/auth/presentation/data/request/EmailRequest.java @@ -1,4 +1,4 @@ -package com.ampersand.groom.domain.auth.presentation.dto; +package com.ampersand.groom.domain.auth.presentation.data.request; import jakarta.validation.constraints.Email; import lombok.Getter; diff --git a/src/main/java/com/ampersand/groom/domain/auth/presentation/data/request/RefreshRequest.java b/src/main/java/com/ampersand/groom/domain/auth/presentation/data/request/RefreshRequest.java new file mode 100644 index 0000000..4e09455 --- /dev/null +++ b/src/main/java/com/ampersand/groom/domain/auth/presentation/data/request/RefreshRequest.java @@ -0,0 +1,16 @@ +package com.ampersand.groom.domain.auth.presentation.data.request; + +import jakarta.validation.constraints.NotBlank; +import lombok.Getter; + +@Getter +public class RefreshRequest { + + @NotBlank(message = "Refresh token is required.") + private final String refreshToken; + + public RefreshRequest(String refreshToken) { + this.refreshToken = refreshToken; + } + +} diff --git a/src/main/java/com/ampersand/groom/domain/auth/presentation/data/request/SignInRequest.java b/src/main/java/com/ampersand/groom/domain/auth/presentation/data/request/SignInRequest.java new file mode 100644 index 0000000..2b6da8c --- /dev/null +++ b/src/main/java/com/ampersand/groom/domain/auth/presentation/data/request/SignInRequest.java @@ -0,0 +1,26 @@ +package com.ampersand.groom.domain.auth.presentation.data.request; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Getter; + +@Getter +public class SignInRequest { + + private static final int EMAIL_MAX_LENGTH = 16; + private static final int PASSWORD_MAX_LENGTH = 30; + + @Email(message = "Invalid email format") + @Size(max = EMAIL_MAX_LENGTH, message = "Invalid email format") + private final String email; + + @NotBlank(message = "Invalid password format") + @Size(max = PASSWORD_MAX_LENGTH, message = "Invalid password format") + private final String password; + + public SignInRequest(String email, String password) { + this.email = email; + this.password = password; + } +} diff --git a/src/main/java/com/ampersand/groom/domain/auth/presentation/data/request/SignupRequest.java b/src/main/java/com/ampersand/groom/domain/auth/presentation/data/request/SignupRequest.java new file mode 100644 index 0000000..8c74c41 --- /dev/null +++ b/src/main/java/com/ampersand/groom/domain/auth/presentation/data/request/SignupRequest.java @@ -0,0 +1,24 @@ +package com.ampersand.groom.domain.auth.presentation.data.request; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import lombok.Getter; + +@Getter +public class SignupRequest { + + @Email(message = "Invalid format") + private final String email; + + @NotBlank(message = "Password cannot be blank") + private final String password; + + @NotBlank(message = "Name cannot be blank") + private final String name; + + public SignupRequest(String email, String password, String name) { + this.email = email; + this.password = password; + this.name = name; + } +} diff --git a/src/main/java/com/ampersand/groom/domain/auth/presentation/dto/VerificationCodeRequest.java b/src/main/java/com/ampersand/groom/domain/auth/presentation/data/request/VerificationCodeRequest.java similarity index 81% rename from src/main/java/com/ampersand/groom/domain/auth/presentation/dto/VerificationCodeRequest.java rename to src/main/java/com/ampersand/groom/domain/auth/presentation/data/request/VerificationCodeRequest.java index 5f4e41e..8ef39b0 100644 --- a/src/main/java/com/ampersand/groom/domain/auth/presentation/dto/VerificationCodeRequest.java +++ b/src/main/java/com/ampersand/groom/domain/auth/presentation/data/request/VerificationCodeRequest.java @@ -1,4 +1,4 @@ -package com.ampersand.groom.domain.auth.presentation.dto; +package com.ampersand.groom.domain.auth.presentation.data.request; import jakarta.validation.constraints.Size; import lombok.Getter; diff --git a/src/main/java/com/ampersand/groom/domain/auth/presentation/data/response/RefreshResponse.java b/src/main/java/com/ampersand/groom/domain/auth/presentation/data/response/RefreshResponse.java new file mode 100644 index 0000000..f320e32 --- /dev/null +++ b/src/main/java/com/ampersand/groom/domain/auth/presentation/data/response/RefreshResponse.java @@ -0,0 +1,24 @@ +package com.ampersand.groom.domain.auth.presentation.data.response; + +import java.time.LocalDateTime; + +import com.ampersand.groom.domain.member.domain.Member; +import lombok.Getter; + +@Getter +public class RefreshResponse { + + private final String accessToken; + private final String refreshToken; + private final LocalDateTime accessTokenExpiresAt; + private final LocalDateTime refreshTokenExpiredAt; + private final Member role; + + public RefreshResponse(String accessToken, String refreshToken, LocalDateTime accessTokenExpiresAt, LocalDateTime refreshTokenExpiredAt, Member role) { + this.accessToken = accessToken; + this.refreshToken = refreshToken; + this.accessTokenExpiresAt = accessTokenExpiresAt; + this.refreshTokenExpiredAt = refreshTokenExpiredAt; + this.role = role; + } +} diff --git a/src/main/java/com/ampersand/groom/domain/auth/presentation/data/response/SignInResponse.java b/src/main/java/com/ampersand/groom/domain/auth/presentation/data/response/SignInResponse.java new file mode 100644 index 0000000..7a80843 --- /dev/null +++ b/src/main/java/com/ampersand/groom/domain/auth/presentation/data/response/SignInResponse.java @@ -0,0 +1,25 @@ +package com.ampersand.groom.domain.auth.presentation.data.response; + +import com.ampersand.groom.domain.member.domain.Member; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +public class SignInResponse { + + private final String accessToken; + private final String refreshToken; + private final LocalDateTime accessTokenExpiresAt; + private final LocalDateTime refreshTokenExpiresAt; + private final Member role; + + + public SignInResponse(String accessToken, String refreshToken, LocalDateTime accessTokenExpiresAt, LocalDateTime refreshTokenExpiresAt, Member role) { + this.accessToken = accessToken; + this.refreshToken = refreshToken; + this.accessTokenExpiresAt = accessTokenExpiresAt; + this.refreshTokenExpiresAt = refreshTokenExpiresAt; + this.role = role; + } +} diff --git a/src/main/java/com/ampersand/groom/global/error/ErrorCode.java b/src/main/java/com/ampersand/groom/global/error/ErrorCode.java index cc6d639..ec5c11b 100644 --- a/src/main/java/com/ampersand/groom/global/error/ErrorCode.java +++ b/src/main/java/com/ampersand/groom/global/error/ErrorCode.java @@ -9,9 +9,19 @@ public enum ErrorCode { MEMBER_ALREADY_EXISTS("Member already exists", 409), MEMBER_NOT_FOUND("Member not found", 404), + VERIFICATION_CODE_EXPIRED_OR_INVALID("Verification code expired or invalid", 401), EMAIL_FORMAT_INVALID("Email format invalid", 400), - VERIFICATION_CODE_FORMAT_INVALID("Verification code format invalid", 400); + VERIFICATION_CODE_FORMAT_INVALID("Verification code format invalid", 400), + + PASSWORD_INVALID("Password invalid", 401), + USER_ALREADY_EXISTS("User already exists", 409), + USER_NOT_FOUND("User not found", 404), + USER_FORBIDDEN("Email verification is not complete", 403), + EMAIL_OR_PASSWORD_EMPTY("email or password is empty", 400), + REFRESH_TOKEN_EXPIRED_OR_INVALID("Refresh token expired or invalid", 401), + REFRESH_TOKEN_REQUEST_FORMAT_INVALID("Refresh token request format invalid", 400); + private final String message; private final int httpStatus; diff --git a/src/main/java/com/ampersand/groom/global/security/config/JwtAuthenticationFilter.java b/src/main/java/com/ampersand/groom/global/security/config/JwtAuthenticationFilter.java new file mode 100644 index 0000000..bd9e5b5 --- /dev/null +++ b/src/main/java/com/ampersand/groom/global/security/config/JwtAuthenticationFilter.java @@ -0,0 +1,44 @@ +package com.ampersand.groom.global.security.config; + +import com.ampersand.groom.domain.auth.application.service.CustomUserDetailsService; +import com.ampersand.groom.domain.auth.application.service.JwtService; +import com.ampersand.groom.domain.auth.expection.UserNotFoundException; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtService jwtService; + private final CustomUserDetailsService customUserDetailsService; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + + String token = jwtService.resolveToken(request); + + if (token != null && jwtService.validateToken(token)) { + String email = jwtService.getEmailFromToken(token); + UserDetails userDetails = customUserDetailsService.loadUserByUsername(email); + if (userDetails != null) { + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); + SecurityContextHolder.getContext().setAuthentication(authentication); + } else { + throw new UserNotFoundException(); + } + + } + filterChain.doFilter(request, response); + } +} diff --git a/src/main/java/com/ampersand/groom/global/security/config/JwtConfig.java b/src/main/java/com/ampersand/groom/global/security/config/JwtConfig.java new file mode 100644 index 0000000..530e3f9 --- /dev/null +++ b/src/main/java/com/ampersand/groom/global/security/config/JwtConfig.java @@ -0,0 +1,40 @@ +package com.ampersand.groom.global.security.config; + + +import io.jsonwebtoken.SignatureAlgorithm; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; + +@Configuration +public class JwtConfig { + + @Value("${spring.jwt.secret}") + private String secretKey; + + @Value("${spring.jwt.token.access-expiration}") + private long accessTokenExpiration; + + @Value("${spring.jwt.token.refresh-expiration}") + private long refreshTokenExpiration; + + @Bean + public SecretKey secretKey() { + return new SecretKeySpec(secretKey.getBytes(StandardCharsets.UTF_8), SignatureAlgorithm.HS256.getJcaName()); + } + + @Bean + public long accessTokenExpiration() { + return accessTokenExpiration; + } + + @Bean + public long refreshTokenExpiration() { + return refreshTokenExpiration; + } + +} diff --git a/src/main/java/com/ampersand/groom/global/security/config/SecurityConfig.java b/src/main/java/com/ampersand/groom/global/security/config/SecurityConfig.java index 5613525..7d5fdf4 100644 --- a/src/main/java/com/ampersand/groom/global/security/config/SecurityConfig.java +++ b/src/main/java/com/ampersand/groom/global/security/config/SecurityConfig.java @@ -1,32 +1,56 @@ package com.ampersand.groom.global.security.config; +import com.ampersand.groom.domain.auth.application.service.CustomUserDetailsService; +import com.ampersand.groom.domain.auth.application.service.JwtService; +import com.ampersand.groom.global.error.exception.GroomException; +import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.web.SecurityFilterChain; -import org.springframework.stereotype.Component; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; // TODO: 개발시작하면 실제로 구현해주세요 -@Component +@Configuration @EnableWebSecurity +@RequiredArgsConstructor public class SecurityConfig { + private final JwtService jwtService; + private final CustomUserDetailsService customUserDetailsService; + + @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .cors(AbstractHttpConfigurer::disable) .csrf(AbstractHttpConfigurer::disable) - .authorizeHttpRequests((authorize) -> { - authorize.anyRequest().permitAll(); - }) + .authorizeHttpRequests(authorizeRequests -> + authorizeRequests + .requestMatchers("/auth/**").permitAll() + .anyRequest().authenticated() + ) .formLogin(AbstractHttpConfigurer::disable) - .sessionManagement((sessionManagement) -> { - sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS); - }) - .httpBasic(AbstractHttpConfigurer::disable); + .httpBasic(AbstractHttpConfigurer::disable) + .sessionManagement(sessionManagement -> + sessionManagement + .sessionCreationPolicy(SessionCreationPolicy.STATELESS) + ) + .addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class); return http.build(); + } + @Bean + public JwtAuthenticationFilter jwtAuthenticationFilter() throws GroomException { + return new JwtAuthenticationFilter(jwtService, customUserDetailsService); + } + + @Bean + public BCryptPasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); } -} +} \ No newline at end of file diff --git a/src/main/resources/application-test.yml b/src/main/resources/application-test.yml index f6affc9..142f08b 100644 --- a/src/main/resources/application-test.yml +++ b/src/main/resources/application-test.yml @@ -13,6 +13,11 @@ spring: redis: host: localhost port: 6379 + jwt: + secret: test-jwt-secret-key + token: + access-expiration: 7200000 + refresh-expiration: 2592000000 email: host: smtp.example.com port: 587 diff --git a/src/test/java/com/ampersand/groom/domain/auth/application/AuthServiceTest.java b/src/test/java/com/ampersand/groom/domain/auth/application/AuthServiceTest.java new file mode 100644 index 0000000..ab9c4d1 --- /dev/null +++ b/src/test/java/com/ampersand/groom/domain/auth/application/AuthServiceTest.java @@ -0,0 +1,192 @@ +package com.ampersand.groom.domain.auth.application; + +import com.ampersand.groom.domain.auth.application.service.AuthService; +import com.ampersand.groom.domain.auth.domain.JwtToken; +import com.ampersand.groom.domain.auth.expection.*; +import com.ampersand.groom.domain.auth.presentation.data.request.SignupRequest; +import com.ampersand.groom.domain.member.domain.constant.MemberRole; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.Instant; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("AuthService 클래스의") +class AuthServiceTest { + + @Mock + private AuthService authService; + + @DisplayName("signIn 메서드는") + @Nested + class Describe_signIn { + + @Nested + @DisplayName("정상적인 로그인 시") + class Context_with_valid_credentials { + + @Test + @DisplayName("JWT 토큰을 반환한다.") + void it_returns_jwt_token() { + // given + String email = "test@example.com"; + String password = "password123"; + JwtToken validToken = JwtToken.builder() + .accessToken("validAccessToken") + .refreshToken("validRefreshToken") + .accessTokenExpiration(Instant.now().plusMillis(1000)) + .refreshTokenExpiration(Instant.now().plusMillis(1000)) + .role(MemberRole.ROLE_STUDENT) + .build(); + when(authService.signIn(email, password)).thenReturn(validToken); + + // when + JwtToken result = authService.signIn(email, password); + + // then + verify(authService).signIn(email, password); + assertNotNull(result); + assertEquals("validAccessToken", result.getAccessToken()); + } + } + + @Nested + @DisplayName("잘못된 로그인 시도 시") + class Context_with_invalid_credentials { + + @Test + @DisplayName("PasswordInvalidException을 던진다.") + void it_throws_password_invalid_exception() { + // given + String email = "test@example.com"; + String password = "wrongPassword"; + when(authService.signIn(email, password)).thenThrow(new PasswordInvalidException()); + + // when & then + assertThrows(PasswordInvalidException.class, () -> authService.signIn(email, password)); + } + + @Test + @DisplayName("UserNotFoundException을 던진다.") + void it_throws_user_not_found_exception() { + // given + String email = "unknown@example.com"; + String password = "password123"; + when(authService.signIn(email, password)).thenThrow(new UserNotFoundException()); + + // when & then + assertThrows(UserNotFoundException.class, () -> authService.signIn(email, password)); + } + } + } + + @DisplayName("signup 메서드는") + @Nested + class Describe_signup { + + @Nested + @DisplayName("정상적인 회원가입 시") + class Context_with_valid_signup { + + @Test + @DisplayName("회원가입을 성공적으로 수행한다.") + void it_signs_up_successfully() { + // given + SignupRequest signupRequest = new SignupRequest("test@example.com", "password123", "John Doe"); + doNothing().when(authService).signup(signupRequest); + + // when + assertDoesNotThrow(() -> authService.signup(signupRequest)); + + // then + verify(authService).signup(signupRequest); + } + } + + @Nested + @DisplayName("중복된 이메일로 회원가입 시") + class Context_with_existing_email { + + @Test + @DisplayName("UserExistException을 던진다.") + void it_throws_user_exist_exception() { + // given + SignupRequest signupRequest = new SignupRequest("test@example.com", "password123", "John Doe"); + doThrow(new UserExistException()).when(authService).signup(signupRequest); + + // when & then + assertThrows(UserExistException.class, () -> authService.signup(signupRequest)); + } + } + } + + @DisplayName("refreshToken 메서드는") + @Nested + class Describe_refreshToken { + + @Nested + @DisplayName("정상적인 리프레시 토큰") + class Context_with_valid_refresh_token { + + @Test + @DisplayName("새로운 JWT 토큰을 반환한다.") + void it_returns_new_jwt_token() { + // given + String refreshToken = "validRefreshToken"; + JwtToken validToken = JwtToken.builder() + .accessToken("newAccessToken") + .refreshToken("newRefreshToken") + .accessTokenExpiration(Instant.now().plusMillis(1000)) + .refreshTokenExpiration(Instant.now().plusMillis(1000)) + .role(MemberRole.ROLE_STUDENT) + .build(); + when(authService.refreshToken(refreshToken)).thenReturn(validToken); + + // when + JwtToken result = authService.refreshToken(refreshToken); + + // then + verify(authService).refreshToken(refreshToken); + assertNotNull(result); + assertEquals("newAccessToken", result.getAccessToken()); + } + } + + @Nested + @DisplayName("잘못된 리프레시 토큰") + class Context_with_invalid_refresh_token { + + @Test + @DisplayName("RefreshTokenExpiredOrInvalidException을 던진다.") + void it_throws_refresh_token_expired_or_invalid_exception() { + // given + String refreshToken = "invalidRefreshToken"; + when(authService.refreshToken(refreshToken)).thenThrow(new RefreshTokenExpiredOrInvalidException()); + + // when & then + assertThrows(RefreshTokenExpiredOrInvalidException.class, () -> authService.refreshToken(refreshToken)); + } + } + + @Nested + @DisplayName("리프레시 토큰이 없거나 비어있는 경우") + class Context_with_empty_refresh_token { + + @Test + @DisplayName("RefreshTokenRequestFormatInvalidException을 던진다.") + void it_throws_refresh_token_request_format_invalid_exception() { + // given + String refreshToken = ""; + when(authService.refreshToken(refreshToken)).thenThrow(new RefreshTokenRequestFormatInvalidException()); + + // when & then + assertThrows(RefreshTokenRequestFormatInvalidException.class, () -> authService.refreshToken(refreshToken)); + } + } + } +}