diff --git a/.gitignore b/.gitignore index f6e780891..ad1756662 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,8 @@ out/ ### Claude Code ### .claude/settings.local.json +.omc/ +docs/superpowers/ ### Serena ### .serena/ diff --git a/src/main/java/com/example/solidconnection/admin/auth/controller/AdminAuthController.java b/src/main/java/com/example/solidconnection/admin/auth/controller/AdminAuthController.java new file mode 100644 index 000000000..364889646 --- /dev/null +++ b/src/main/java/com/example/solidconnection/admin/auth/controller/AdminAuthController.java @@ -0,0 +1,63 @@ +package com.example.solidconnection.admin.auth.controller; + +import com.example.solidconnection.admin.auth.dto.AdminReissueResponse; +import com.example.solidconnection.admin.auth.dto.AdminSignInRequest; +import com.example.solidconnection.admin.auth.dto.AdminSignInResponse; +import com.example.solidconnection.admin.auth.dto.AdminSignInResult; +import com.example.solidconnection.admin.auth.service.AdminAuthService; +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.common.exception.ErrorCode; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +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; + +@RestController +@RequestMapping("/admin/auth") +@RequiredArgsConstructor +public class AdminAuthController { + + private final AdminAuthService adminAuthService; + private final AdminRefreshTokenCookieManager adminRefreshTokenCookieManager; + + @PostMapping("/sign-in") + public ResponseEntity signIn( + @RequestBody @Valid AdminSignInRequest request, + HttpServletResponse response + ) { + AdminSignInResult result = adminAuthService.signIn(request); + adminRefreshTokenCookieManager.setCookie(response, result.adminRefreshToken()); + return ResponseEntity.ok(AdminSignInResponse.from(result.accessToken())); + } + + @PostMapping("/reissue") + public ResponseEntity reissue(HttpServletRequest request) { + String adminRefreshToken = adminRefreshTokenCookieManager.getAdminRefreshToken(request); + AdminReissueResponse reissueResponse = adminAuthService.reissue(adminRefreshToken); + return ResponseEntity.ok(reissueResponse); + } + + @PostMapping("/sign-out") + public ResponseEntity signOut( + Authentication authentication, + HttpServletResponse response + ) { + String accessToken = getAccessToken(authentication); + adminAuthService.signOut(accessToken); + adminRefreshTokenCookieManager.deleteCookie(response); + return ResponseEntity.ok().build(); + } + + private String getAccessToken(Authentication authentication) { + if (authentication == null || !(authentication.getCredentials() instanceof String accessToken)) { + throw new CustomException(ErrorCode.AUTHENTICATION_FAILED, "엑세스 토큰이 없습니다."); + } + return accessToken; + } +} diff --git a/src/main/java/com/example/solidconnection/admin/auth/controller/AdminRefreshTokenCookieManager.java b/src/main/java/com/example/solidconnection/admin/auth/controller/AdminRefreshTokenCookieManager.java new file mode 100644 index 000000000..6b95d2387 --- /dev/null +++ b/src/main/java/com/example/solidconnection/admin/auth/controller/AdminRefreshTokenCookieManager.java @@ -0,0 +1,69 @@ +package com.example.solidconnection.admin.auth.controller; + +import static com.example.solidconnection.common.exception.ErrorCode.ADMIN_REFRESH_TOKEN_NOT_EXISTS; + +import com.example.solidconnection.admin.auth.controller.config.AdminRefreshTokenCookieProperties; +import com.example.solidconnection.auth.token.config.TokenProperties; +import com.example.solidconnection.common.exception.CustomException; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.time.Duration; +import java.util.Arrays; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.web.server.Cookie.SameSite; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseCookie; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class AdminRefreshTokenCookieManager { + + private static final String PATH = "/"; + + private final AdminRefreshTokenCookieProperties properties; + private final TokenProperties tokenProperties; + + public void setCookie(HttpServletResponse response, String adminRefreshToken) { + Duration tokenExpireTime = tokenProperties.adminRefresh().expireTime(); + long cookieMaxAge = tokenExpireTime.toSeconds(); + setAdminRefreshTokenCookie(response, adminRefreshToken, cookieMaxAge); + } + + public void deleteCookie(HttpServletResponse response) { + setAdminRefreshTokenCookie(response, "", 0); + } + + private void setAdminRefreshTokenCookie( + HttpServletResponse response, String adminRefreshToken, long maxAge + ) { + ResponseCookie cookie = ResponseCookie.from(properties.cookieName(), adminRefreshToken) + .httpOnly(true) + .secure(true) + .path(PATH) + .maxAge(maxAge) + .domain(properties.cookieDomain()) + .sameSite(SameSite.STRICT.attributeValue()) + .build(); + response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString()); + } + + public String getAdminRefreshToken(HttpServletRequest request) { + Cookie[] cookies = request.getCookies(); + if (cookies == null || cookies.length == 0) { + throw new CustomException(ADMIN_REFRESH_TOKEN_NOT_EXISTS); + } + + Cookie adminRefreshTokenCookie = Arrays.stream(cookies) + .filter(cookie -> properties.cookieName().equals(cookie.getName())) + .findFirst() + .orElseThrow(() -> new CustomException(ADMIN_REFRESH_TOKEN_NOT_EXISTS)); + + String adminRefreshToken = adminRefreshTokenCookie.getValue(); + if (adminRefreshToken == null || adminRefreshToken.isBlank()) { + throw new CustomException(ADMIN_REFRESH_TOKEN_NOT_EXISTS); + } + return adminRefreshToken; + } +} diff --git a/src/main/java/com/example/solidconnection/admin/auth/controller/config/AdminRefreshTokenCookieProperties.java b/src/main/java/com/example/solidconnection/admin/auth/controller/config/AdminRefreshTokenCookieProperties.java new file mode 100644 index 000000000..492756542 --- /dev/null +++ b/src/main/java/com/example/solidconnection/admin/auth/controller/config/AdminRefreshTokenCookieProperties.java @@ -0,0 +1,11 @@ +package com.example.solidconnection.admin.auth.controller.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "token.admin-refresh") +public record AdminRefreshTokenCookieProperties( + String cookieName, + String cookieDomain +) { + +} diff --git a/src/main/java/com/example/solidconnection/admin/auth/dto/AdminReissueResponse.java b/src/main/java/com/example/solidconnection/admin/auth/dto/AdminReissueResponse.java new file mode 100644 index 000000000..8a9ceea38 --- /dev/null +++ b/src/main/java/com/example/solidconnection/admin/auth/dto/AdminReissueResponse.java @@ -0,0 +1,12 @@ +package com.example.solidconnection.admin.auth.dto; + +import com.example.solidconnection.auth.domain.AccessToken; + +public record AdminReissueResponse( + String accessToken +) { + + public static AdminReissueResponse from(AccessToken accessToken) { + return new AdminReissueResponse(accessToken.token()); + } +} diff --git a/src/main/java/com/example/solidconnection/admin/auth/dto/AdminSignInRequest.java b/src/main/java/com/example/solidconnection/admin/auth/dto/AdminSignInRequest.java new file mode 100644 index 000000000..a88ab2b16 --- /dev/null +++ b/src/main/java/com/example/solidconnection/admin/auth/dto/AdminSignInRequest.java @@ -0,0 +1,10 @@ +package com.example.solidconnection.admin.auth.dto; + +import jakarta.validation.constraints.NotBlank; + +public record AdminSignInRequest( + @NotBlank String email, + @NotBlank String password +) { + +} diff --git a/src/main/java/com/example/solidconnection/admin/auth/dto/AdminSignInResponse.java b/src/main/java/com/example/solidconnection/admin/auth/dto/AdminSignInResponse.java new file mode 100644 index 000000000..97ec0de09 --- /dev/null +++ b/src/main/java/com/example/solidconnection/admin/auth/dto/AdminSignInResponse.java @@ -0,0 +1,10 @@ +package com.example.solidconnection.admin.auth.dto; + +public record AdminSignInResponse( + String accessToken +) { + + public static AdminSignInResponse from(String accessToken) { + return new AdminSignInResponse(accessToken); + } +} diff --git a/src/main/java/com/example/solidconnection/admin/auth/dto/AdminSignInResult.java b/src/main/java/com/example/solidconnection/admin/auth/dto/AdminSignInResult.java new file mode 100644 index 000000000..562b6af7f --- /dev/null +++ b/src/main/java/com/example/solidconnection/admin/auth/dto/AdminSignInResult.java @@ -0,0 +1,17 @@ +package com.example.solidconnection.admin.auth.dto; + +import com.example.solidconnection.auth.domain.AccessToken; +import com.example.solidconnection.auth.domain.AdminRefreshToken; + +public record AdminSignInResult( + String accessToken, + String adminRefreshToken +) { + + public static AdminSignInResult of( + AccessToken accessToken, + AdminRefreshToken adminRefreshToken + ) { + return new AdminSignInResult(accessToken.token(), adminRefreshToken.token()); + } +} diff --git a/src/main/java/com/example/solidconnection/admin/auth/service/AdminAuthService.java b/src/main/java/com/example/solidconnection/admin/auth/service/AdminAuthService.java new file mode 100644 index 000000000..91b340db4 --- /dev/null +++ b/src/main/java/com/example/solidconnection/admin/auth/service/AdminAuthService.java @@ -0,0 +1,82 @@ +package com.example.solidconnection.admin.auth.service; + +import static com.example.solidconnection.common.exception.ErrorCode.ADMIN_REFRESH_TOKEN_EXPIRED; +import static com.example.solidconnection.common.exception.ErrorCode.NOT_ADMIN_USER; +import static com.example.solidconnection.common.exception.ErrorCode.SIGN_IN_FAILED; + +import com.example.solidconnection.admin.auth.dto.AdminReissueResponse; +import com.example.solidconnection.admin.auth.dto.AdminSignInRequest; +import com.example.solidconnection.admin.auth.dto.AdminSignInResult; +import com.example.solidconnection.auth.domain.AccessToken; +import com.example.solidconnection.auth.domain.AdminRefreshToken; +import com.example.solidconnection.auth.exception.AuthException; +import com.example.solidconnection.auth.service.AuthTokenProvider; +import com.example.solidconnection.auth.token.TokenBlackListService; +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.siteuser.domain.AuthType; +import com.example.solidconnection.siteuser.domain.Role; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class AdminAuthService { + + private final AuthTokenProvider authTokenProvider; + private final TokenBlackListService tokenBlackListService; + private final SiteUserRepository siteUserRepository; + private final PasswordEncoder passwordEncoder; + + @Transactional + public AdminSignInResult signIn(AdminSignInRequest request) { + SiteUser siteUser = getEmailMatchingUserOrThrow(request.email()); + validatePassword(request.password(), siteUser.getPassword()); + validateAdminRole(siteUser); + resetQuitedAt(siteUser); + AccessToken accessToken = authTokenProvider.generateAccessToken(siteUser); + AdminRefreshToken adminRefreshToken = authTokenProvider.generateAndSaveAdminRefreshToken(siteUser); + return AdminSignInResult.of(accessToken, adminRefreshToken); + } + + private SiteUser getEmailMatchingUserOrThrow(String email) { + return siteUserRepository.findByEmailAndAuthType(email, AuthType.EMAIL) + .orElseThrow(() -> new CustomException(SIGN_IN_FAILED)); + } + + private void validatePassword(String rawPassword, String encodedPassword) { + if (!passwordEncoder.matches(rawPassword, encodedPassword)) { + throw new CustomException(SIGN_IN_FAILED); + } + } + + private void validateAdminRole(SiteUser siteUser) { + if (!Role.ADMIN.equals(siteUser.getRole())) { + throw new CustomException(NOT_ADMIN_USER); + } + } + + private void resetQuitedAt(SiteUser siteUser) { + if (siteUser.getQuitedAt() == null) { + return; + } + siteUser.setQuitedAt(null); + } + + public AdminReissueResponse reissue(String requestedAdminRefreshToken) { + if (!authTokenProvider.isValidAdminRefreshToken(requestedAdminRefreshToken)) { + throw new AuthException(ADMIN_REFRESH_TOKEN_EXPIRED); + } + SiteUser siteUser = authTokenProvider.parseSiteUser(requestedAdminRefreshToken); + AccessToken newAccessToken = authTokenProvider.generateAccessToken(siteUser); + return AdminReissueResponse.from(newAccessToken); + } + + public void signOut(String accessToken) { + tokenBlackListService.addToBlacklist(accessToken); + authTokenProvider.deleteAdminRefreshTokenByAccessToken(accessToken); + } +} diff --git a/src/main/java/com/example/solidconnection/application/dto/ApplicantsResponse.java b/src/main/java/com/example/solidconnection/application/dto/ApplicantsResponse.java index 10a794fc9..ee1677b87 100644 --- a/src/main/java/com/example/solidconnection/application/dto/ApplicantsResponse.java +++ b/src/main/java/com/example/solidconnection/application/dto/ApplicantsResponse.java @@ -11,6 +11,8 @@ public record ApplicantsResponse( Integer studentCapacity, String region, String country, + String logoImageUrl, + String backgroundImageUrl, List applicants) { public static ApplicantsResponse of(UnivApplyInfo univApplyInfo, List applications, SiteUser siteUser) { @@ -19,6 +21,8 @@ public static ApplicantsResponse of(UnivApplyInfo univApplyInfo, List ApplicantResponse.of(application, isUsers(application, siteUser))) .toList()); diff --git a/src/main/java/com/example/solidconnection/auth/controller/RefreshTokenCookieManager.java b/src/main/java/com/example/solidconnection/auth/controller/RefreshTokenCookieManager.java index a44ed6153..4217ac56e 100644 --- a/src/main/java/com/example/solidconnection/auth/controller/RefreshTokenCookieManager.java +++ b/src/main/java/com/example/solidconnection/auth/controller/RefreshTokenCookieManager.java @@ -49,7 +49,7 @@ private void setRefreshTokenCookie( .path(PATH) .maxAge(maxAge) .domain(properties.cookieDomain()) - .sameSite(SameSite.LAX.attributeValue()) + .sameSite(SameSite.STRICT.attributeValue()) .build(); response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString()); } diff --git a/src/main/java/com/example/solidconnection/auth/domain/AdminRefreshToken.java b/src/main/java/com/example/solidconnection/auth/domain/AdminRefreshToken.java new file mode 100644 index 000000000..ae1788e43 --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/domain/AdminRefreshToken.java @@ -0,0 +1,7 @@ +package com.example.solidconnection.auth.domain; + +public record AdminRefreshToken( + String token +) implements Token { + +} diff --git a/src/main/java/com/example/solidconnection/auth/service/AuthTokenProvider.java b/src/main/java/com/example/solidconnection/auth/service/AuthTokenProvider.java index 0f5e46c23..363ec4dd5 100644 --- a/src/main/java/com/example/solidconnection/auth/service/AuthTokenProvider.java +++ b/src/main/java/com/example/solidconnection/auth/service/AuthTokenProvider.java @@ -3,6 +3,7 @@ import static com.example.solidconnection.common.exception.ErrorCode.USER_NOT_FOUND; import com.example.solidconnection.auth.domain.AccessToken; +import com.example.solidconnection.auth.domain.AdminRefreshToken; import com.example.solidconnection.auth.domain.RefreshToken; import com.example.solidconnection.auth.domain.Subject; import com.example.solidconnection.auth.token.config.TokenProperties; @@ -54,6 +55,16 @@ public RefreshToken generateAndSaveRefreshToken(SiteUser siteUser) { return tokenStorage.saveToken(subject, refreshToken); } + public AdminRefreshToken generateAndSaveAdminRefreshToken(SiteUser siteUser) { + Subject subject = toSubject(siteUser); + String token = tokenProvider.generateToken( + subject, + tokenProperties.adminRefresh().expireTime() + ); + AdminRefreshToken adminRefreshToken = new AdminRefreshToken(token); + return tokenStorage.saveToken(subject, adminRefreshToken); + } + /* * 유효한 리프레시 토큰인지 확인한다. * - 요청된 토큰과 같은 subject 의 리프레시 토큰을 조회한다. @@ -66,11 +77,23 @@ public boolean isValidRefreshToken(String requestedRefreshToken) { .orElse(false); } + public boolean isValidAdminRefreshToken(String requestedAdminRefreshToken) { + Subject subject = tokenProvider.parseSubject(requestedAdminRefreshToken); + return tokenStorage.findToken(subject, AdminRefreshToken.class) + .map(foundToken -> Objects.equals(foundToken, requestedAdminRefreshToken)) + .orElse(false); + } + public void deleteRefreshTokenByAccessToken(String accessToken) { Subject subject = tokenProvider.parseSubject(accessToken); tokenStorage.deleteToken(subject, RefreshToken.class); } + public void deleteAdminRefreshTokenByAccessToken(String accessToken) { + Subject subject = tokenProvider.parseSubject(accessToken); + tokenStorage.deleteToken(subject, AdminRefreshToken.class); + } + public SiteUser parseSiteUser(String token) { Subject subject = tokenProvider.parseSubject(token); long siteUserId = Long.parseLong(subject.value()); diff --git a/src/main/java/com/example/solidconnection/auth/token/config/TokenProperties.java b/src/main/java/com/example/solidconnection/auth/token/config/TokenProperties.java index ba239dc9a..e200c15b5 100644 --- a/src/main/java/com/example/solidconnection/auth/token/config/TokenProperties.java +++ b/src/main/java/com/example/solidconnection/auth/token/config/TokenProperties.java @@ -1,6 +1,7 @@ package com.example.solidconnection.auth.token.config; import com.example.solidconnection.auth.domain.AccessToken; +import com.example.solidconnection.auth.domain.AdminRefreshToken; import com.example.solidconnection.auth.domain.RefreshToken; import com.example.solidconnection.auth.domain.SignUpToken; import com.example.solidconnection.auth.domain.Token; @@ -13,6 +14,7 @@ public record TokenProperties( TokenConfig access, TokenConfig refresh, + TokenConfig adminRefresh, TokenConfig signUp, TokenConfig blackList ) { @@ -32,6 +34,7 @@ public void init() { tokenConfigs = Map.of( AccessToken.class, access, RefreshToken.class, refresh, + AdminRefreshToken.class, adminRefresh, SignUpToken.class, signUp ); } diff --git a/src/main/java/com/example/solidconnection/common/exception/CustomExceptionHandler.java b/src/main/java/com/example/solidconnection/common/exception/CustomExceptionHandler.java index 09057005f..de281a7af 100644 --- a/src/main/java/com/example/solidconnection/common/exception/CustomExceptionHandler.java +++ b/src/main/java/com/example/solidconnection/common/exception/CustomExceptionHandler.java @@ -1,5 +1,6 @@ package com.example.solidconnection.common.exception; +import static com.example.solidconnection.common.exception.ErrorCode.ADMIN_REFRESH_TOKEN_EXPIRED; import static com.example.solidconnection.common.exception.ErrorCode.DATA_INTEGRITY_VIOLATION; import static com.example.solidconnection.common.exception.ErrorCode.INVALID_INPUT; import static com.example.solidconnection.common.exception.ErrorCode.JSON_PARSING_FAILED; @@ -7,6 +8,7 @@ import static com.example.solidconnection.common.exception.ErrorCode.NOT_DEFINED_ERROR; import static com.example.solidconnection.common.exception.ErrorCode.REFRESH_TOKEN_EXPIRED; +import com.example.solidconnection.admin.auth.controller.AdminRefreshTokenCookieManager; import com.example.solidconnection.auth.controller.RefreshTokenCookieManager; import com.example.solidconnection.auth.exception.AuthException; import com.example.solidconnection.common.response.ErrorResponse; @@ -30,6 +32,7 @@ public class CustomExceptionHandler { private final RefreshTokenCookieManager refreshTokenCookieManager; + private final AdminRefreshTokenCookieManager adminRefreshTokenCookieManager; @ExceptionHandler(AuthException.class) protected ResponseEntity handleAuthException( @@ -40,6 +43,9 @@ protected ResponseEntity handleAuthException( if (ex.getErrorCode().equals(REFRESH_TOKEN_EXPIRED)) { refreshTokenCookieManager.deleteCookie(response); } + if (ex.getErrorCode().equals(ADMIN_REFRESH_TOKEN_EXPIRED)) { + adminRefreshTokenCookieManager.deleteCookie(response); + } ErrorResponse errorResponse = new ErrorResponse(ex); return ResponseEntity .status(ex.getCode()) diff --git a/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java b/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java index 5638c024a..75180f9b0 100644 --- a/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java +++ b/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java @@ -73,8 +73,11 @@ public enum ErrorCode { AUTHENTICATION_FAILED(HttpStatus.UNAUTHORIZED.value(), "인증이 필요한 접근입니다."), ACCESS_TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED.value(), "액세스 토큰이 만료되었습니다. 재발급 api를 호출해주세요."), REFRESH_TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED.value(), "리프레시 토큰이 만료되었습니다. 다시 로그인을 진행해주세요."), + ADMIN_REFRESH_TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED.value(), "어드민 리프레시 토큰이 만료되었습니다. 다시 로그인을 진행해주세요."), ACCESS_DENIED(HttpStatus.FORBIDDEN.value(), "접근 권한이 없습니다."), + NOT_ADMIN_USER(HttpStatus.FORBIDDEN.value(), "어드민 권한이 없는 사용자입니다."), REFRESH_TOKEN_NOT_EXISTS(HttpStatus.BAD_REQUEST.value(), "리프레시 토큰이 존재하지 않습니다."), + ADMIN_REFRESH_TOKEN_NOT_EXISTS(HttpStatus.BAD_REQUEST.value(), "어드민 리프레시 토큰이 존재하지 않습니다."), PASSWORD_MISMATCH(HttpStatus.BAD_REQUEST.value(), "비밀번호가 일치하지 않습니다."), PASSWORD_NOT_CHANGED(HttpStatus.BAD_REQUEST.value(), "현재 비밀번호와 새 비밀번호가 동일합니다."), PASSWORD_NOT_CONFIRMED(HttpStatus.BAD_REQUEST.value(), "새 비밀번호가 일치하지 않습니다."), @@ -83,6 +86,7 @@ public enum ErrorCode { // school email verification SCHOOL_EMAIL_ALREADY_VERIFIED(HttpStatus.BAD_REQUEST.value(), "이미 학교 이메일 인증이 완료되었습니다."), + SCHOOL_EMAIL_ALREADY_USED(HttpStatus.CONFLICT.value(), "이미 인증에 사용된 학교 이메일입니다."), SCHOOL_EMAIL_DOMAIN_NOT_SUPPORTED(HttpStatus.BAD_REQUEST.value(), "지원하지 않는 학교 이메일 도메인입니다."), SCHOOL_EMAIL_CONFIRM_REQUEST_NOT_FOUND(HttpStatus.BAD_REQUEST.value(), "학교 이메일 인증 요청을 찾을 수 없습니다. 인증 코드 발송을 다시 요청해주세요."), SCHOOL_EMAIL_CONFIRM_CODE_DIFFERENT(HttpStatus.BAD_REQUEST.value(), "인증 코드가 일치하지 않습니다."), diff --git a/src/main/java/com/example/solidconnection/community/post/service/UpdateViewCountService.java b/src/main/java/com/example/solidconnection/community/post/service/UpdateViewCountService.java index fb30ea44d..792177306 100644 --- a/src/main/java/com/example/solidconnection/community/post/service/UpdateViewCountService.java +++ b/src/main/java/com/example/solidconnection/community/post/service/UpdateViewCountService.java @@ -22,6 +22,9 @@ public class UpdateViewCountService { public void updateViewCount(String key) { Long postId = postRedisManager.getPostIdFromPostViewCountRedisKey(key); Long viewCount = postRedisManager.getAndDeleteViewCount(key); + if (viewCount == null) { + return; + } postRepository.increaseViewCount(postId, viewCount); } } diff --git a/src/main/java/com/example/solidconnection/security/config/SecurityConfiguration.java b/src/main/java/com/example/solidconnection/security/config/SecurityConfiguration.java index 706fedd52..26acbe59a 100644 --- a/src/main/java/com/example/solidconnection/security/config/SecurityConfiguration.java +++ b/src/main/java/com/example/solidconnection/security/config/SecurityConfiguration.java @@ -63,6 +63,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .sessionManagement((session) -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth .requestMatchers("/connect/**").authenticated() + .requestMatchers("/admin/auth/**").permitAll() .requestMatchers("/admin/**").hasRole(ADMIN.name()) .anyRequest().permitAll() ) diff --git a/src/main/java/com/example/solidconnection/siteuser/domain/SiteUser.java b/src/main/java/com/example/solidconnection/siteuser/domain/SiteUser.java index f072e9eb8..758d2ef4b 100644 --- a/src/main/java/com/example/solidconnection/siteuser/domain/SiteUser.java +++ b/src/main/java/com/example/solidconnection/siteuser/domain/SiteUser.java @@ -30,6 +30,10 @@ @UniqueConstraint( name = "uk_site_user_nickname", columnNames = {"nickname"} + ), + @UniqueConstraint( + name = "uk_site_user_verified_school_email", + columnNames = {"verified_school_email"} ) }) public class SiteUser extends BaseEntity { @@ -54,6 +58,9 @@ public class SiteUser extends BaseEntity { @Column(name = "home_university_id", nullable = true) private Long homeUniversityId; + @Column(name = "verified_school_email", nullable = true, length = 100) + private String verifiedSchoolEmail; + @Setter @Column(name = "profile_image_url", length = 500) private String profileImageUrl; @@ -159,8 +166,9 @@ public void updateUserStatus(UserStatus status) { this.userStatus = status; } - public void verifySchool(Long homeUniversityId) { + public void verifySchool(Long homeUniversityId, String verifiedSchoolEmail) { this.homeUniversityId = homeUniversityId; + this.verifiedSchoolEmail = verifiedSchoolEmail; } public void becomeMentor() { diff --git a/src/main/java/com/example/solidconnection/siteuser/dto/SchoolEmailRequest.java b/src/main/java/com/example/solidconnection/siteuser/dto/SchoolEmailRequest.java index 46f1e9bec..34ebfd9ce 100644 --- a/src/main/java/com/example/solidconnection/siteuser/dto/SchoolEmailRequest.java +++ b/src/main/java/com/example/solidconnection/siteuser/dto/SchoolEmailRequest.java @@ -2,10 +2,12 @@ import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; public record SchoolEmailRequest( @NotBlank(message = "학교 이메일은 필수입니다") @Email(message = "올바른 이메일 형식이 아닙니다") + @Size(max = 100, message = "학교 이메일은 100자 이하여야 합니다") String schoolEmail ) { diff --git a/src/main/java/com/example/solidconnection/siteuser/repository/SiteUserRepository.java b/src/main/java/com/example/solidconnection/siteuser/repository/SiteUserRepository.java index 3d02c77e5..cf33a49e9 100644 --- a/src/main/java/com/example/solidconnection/siteuser/repository/SiteUserRepository.java +++ b/src/main/java/com/example/solidconnection/siteuser/repository/SiteUserRepository.java @@ -20,6 +20,8 @@ public interface SiteUserRepository extends JpaRepository, SiteU boolean existsByNickname(String nickname); + boolean existsByVerifiedSchoolEmail(String verifiedSchoolEmail); + @Query("SELECT u FROM SiteUser u WHERE u.quitedAt <= :cutoffDate") List findUsersToBeRemoved(@Param("cutoffDate") LocalDate cutoffDate); diff --git a/src/main/java/com/example/solidconnection/siteuser/service/SchoolEmailService.java b/src/main/java/com/example/solidconnection/siteuser/service/SchoolEmailService.java index ec8d9c8fc..026704f71 100644 --- a/src/main/java/com/example/solidconnection/siteuser/service/SchoolEmailService.java +++ b/src/main/java/com/example/solidconnection/siteuser/service/SchoolEmailService.java @@ -1,6 +1,7 @@ package com.example.solidconnection.siteuser.service; import static com.example.solidconnection.common.exception.ErrorCode.SCHOOL_EMAIL_ALREADY_VERIFIED; +import static com.example.solidconnection.common.exception.ErrorCode.SCHOOL_EMAIL_ALREADY_USED; import static com.example.solidconnection.common.exception.ErrorCode.SCHOOL_EMAIL_CONFIRM_CODE_DIFFERENT; import static com.example.solidconnection.common.exception.ErrorCode.SCHOOL_EMAIL_CONFIRM_REQUEST_NOT_FOUND; import static com.example.solidconnection.common.exception.ErrorCode.SCHOOL_EMAIL_DOMAIN_NOT_SUPPORTED; @@ -17,9 +18,11 @@ import com.example.solidconnection.university.repository.HomeUniversityRepository; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.Locale; import java.util.concurrent.ThreadLocalRandom; import java.util.concurrent.TimeUnit; import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -46,15 +49,17 @@ public void requestSchoolEmailVerification(long siteUserId, String schoolEmail) throw new CustomException(SCHOOL_EMAIL_ALREADY_VERIFIED); } - String domain = extractEmailDomain(schoolEmail); + String normalizedSchoolEmail = normalizeSchoolEmail(schoolEmail); + String domain = extractEmailDomain(normalizedSchoolEmail); HomeUniversity homeUniversity = homeUniversityRepository.findByEmailDomain(domain) .orElseThrow(() -> new CustomException(SCHOOL_EMAIL_DOMAIN_NOT_SUPPORTED)); + validateVerifiedSchoolEmailNotDuplicated(normalizedSchoolEmail); String code = generateVerificationCode(); - saveVerificationInfo(siteUserId, new SchoolVerificationInfo(schoolEmail, homeUniversity.getId(), code)); + saveVerificationInfo(siteUserId, new SchoolVerificationInfo(normalizedSchoolEmail, homeUniversity.getId(), code)); try { - mailService.sendVerificationEmail(schoolEmail, code); + mailService.sendVerificationEmail(normalizedSchoolEmail, code); } catch (Exception e) { redisTemplate.delete(KEY_PREFIX + siteUserId); throw e; @@ -72,10 +77,21 @@ public void confirmSchoolEmail(long siteUserId, String code) { throw new CustomException(SCHOOL_EMAIL_CONFIRM_CODE_DIFFERENT); } - siteUser.verifySchool(info.getHomeUniversityId()); + String verifiedSchoolEmail = normalizeSchoolEmail(info.getSchoolEmail()); + validateVerifiedSchoolEmailNotDuplicated(verifiedSchoolEmail); + siteUser.verifySchool(info.getHomeUniversityId(), verifiedSchoolEmail); + flushVerifiedSchoolEmail(); redisTemplate.delete(KEY_PREFIX + siteUserId); } + private void flushVerifiedSchoolEmail() { + try { + siteUserRepository.flush(); + } catch (DataIntegrityViolationException e) { + throw new CustomException(SCHOOL_EMAIL_ALREADY_USED); + } + } + private void saveVerificationInfo(long siteUserId, SchoolVerificationInfo info) { try { redisTemplate.opsForValue().set( @@ -95,15 +111,57 @@ private SchoolVerificationInfo getVerificationInfo(long siteUserId) { throw new CustomException(SCHOOL_EMAIL_CONFIRM_REQUEST_NOT_FOUND); } try { - return objectMapper.readValue(jsonInfo, SchoolVerificationInfo.class); + SchoolVerificationInfo info = objectMapper.readValue(jsonInfo, SchoolVerificationInfo.class); + validateVerificationInfo(siteUserId, info); + return info; } catch (JsonProcessingException e) { - redisTemplate.delete(KEY_PREFIX + siteUserId); - throw new CustomException(SCHOOL_EMAIL_VERIFICATION_INFO_CORRUPTED); + throw corruptedVerificationInfoException(siteUserId); + } + } + + private void validateVerificationInfo(long siteUserId, SchoolVerificationInfo info) { + if (info == null + || isBlank(info.getSchoolEmail()) + || !hasEmailDomain(info.getSchoolEmail()) + || info.getHomeUniversityId() == null + || isBlank(info.getCode()) + || !homeUniversityRepository.existsById(info.getHomeUniversityId())) { + throw corruptedVerificationInfoException(siteUserId); + } + } + + private boolean isBlank(String value) { + return value == null || value.isBlank(); + } + + private CustomException corruptedVerificationInfoException(long siteUserId) { + redisTemplate.delete(KEY_PREFIX + siteUserId); + return new CustomException(SCHOOL_EMAIL_VERIFICATION_INFO_CORRUPTED); + } + + private void validateVerifiedSchoolEmailNotDuplicated(String verifiedSchoolEmail) { + if (siteUserRepository.existsByVerifiedSchoolEmail(verifiedSchoolEmail)) { + throw new CustomException(SCHOOL_EMAIL_ALREADY_USED); } } + private String normalizeSchoolEmail(String schoolEmail) { + return schoolEmail.trim().toLowerCase(Locale.ROOT); + } + private String extractEmailDomain(String email) { - return email.substring(email.indexOf('@') + 1).toLowerCase(); + if (!hasEmailDomain(email)) { + throw new CustomException(SCHOOL_EMAIL_DOMAIN_NOT_SUPPORTED); + } + return email.substring(email.indexOf('@') + 1); + } + + private boolean hasEmailDomain(String email) { + if (email == null) { + return false; + } + int atIndex = email.indexOf('@'); + return atIndex > 0 && atIndex < email.length() - 1; } private String generateVerificationCode() { diff --git a/src/main/resources/config/application-variable.yml b/src/main/resources/config/application-variable.yml index 433eecb17..a661e92c2 100644 --- a/src/main/resources/config/application-variable.yml +++ b/src/main/resources/config/application-variable.yml @@ -42,6 +42,9 @@ token: refresh: storage-key-prefix: "REFRESH" expire-time: 90d + admin-refresh: + storage-key-prefix: "ADMIN_REFRESH" + expire-time: 90d sign-up: storage-key-prefix: "SIGN_UP" expire-time: 10m @@ -84,6 +87,9 @@ token: refresh: cookie-name: "prodRefreshToken" cookie-domain: ".solid-connection.com" + admin-refresh: + cookie-name: "adminProdRefreshToken" + cookie-domain: ".solid-connection.com" --- spring: @@ -122,6 +128,9 @@ token: refresh: cookie-name: "stageRefreshToken" cookie-domain: ".stage.solid-connection.com" + admin-refresh: + cookie-name: "adminStageRefreshToken" + cookie-domain: ".stage.solid-connection.com" --- spring: @@ -158,3 +167,6 @@ token: refresh: cookie-name: "refreshToken" cookie-domain: "localhost" + admin-refresh: + cookie-name: "adminRefreshToken" + cookie-domain: "localhost" diff --git a/src/main/resources/db/migration/V56__drop_legacy_application_choice_columns.sql b/src/main/resources/db/migration/V56__drop_legacy_application_choice_columns.sql new file mode 100644 index 000000000..9b0d03893 --- /dev/null +++ b/src/main/resources/db/migration/V56__drop_legacy_application_choice_columns.sql @@ -0,0 +1,13 @@ +ALTER TABLE application + DROP FOREIGN KEY fk_application_first_choice_university_info_for_apply_id, + DROP FOREIGN KEY fk_application_second_choice_university_info_for_apply_id, + DROP FOREIGN KEY fk_application_third_choice_university_info_for_apply_id; + +DROP INDEX idx_app_first_choice_term_id_search ON application; +DROP INDEX idx_app_second_choice_term_id_search ON application; +DROP INDEX idx_app_third_choice_term_id_search ON application; + +ALTER TABLE application + DROP COLUMN first_choice_university_info_for_apply_id, + DROP COLUMN second_choice_university_info_for_apply_id, + DROP COLUMN third_choice_university_info_for_apply_id; diff --git a/src/main/resources/db/migration/V57__add_verified_school_email_to_site_user.sql b/src/main/resources/db/migration/V57__add_verified_school_email_to_site_user.sql new file mode 100644 index 000000000..48c4a357a --- /dev/null +++ b/src/main/resources/db/migration/V57__add_verified_school_email_to_site_user.sql @@ -0,0 +1,3 @@ +ALTER TABLE site_user + ADD COLUMN verified_school_email VARCHAR(100) NULL, + ADD CONSTRAINT uk_site_user_verified_school_email UNIQUE (verified_school_email); diff --git a/src/test/java/com/example/solidconnection/admin/auth/service/AdminAuthServiceTest.java b/src/test/java/com/example/solidconnection/admin/auth/service/AdminAuthServiceTest.java new file mode 100644 index 000000000..ded695bf1 --- /dev/null +++ b/src/test/java/com/example/solidconnection/admin/auth/service/AdminAuthServiceTest.java @@ -0,0 +1,143 @@ +package com.example.solidconnection.admin.auth.service; + +import static com.example.solidconnection.common.exception.ErrorCode.ADMIN_REFRESH_TOKEN_EXPIRED; +import static com.example.solidconnection.common.exception.ErrorCode.NOT_ADMIN_USER; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.example.solidconnection.admin.auth.dto.AdminReissueResponse; +import com.example.solidconnection.admin.auth.dto.AdminSignInRequest; +import com.example.solidconnection.admin.auth.dto.AdminSignInResult; +import com.example.solidconnection.auth.domain.AdminRefreshToken; +import com.example.solidconnection.auth.domain.Subject; +import com.example.solidconnection.auth.exception.AuthException; +import com.example.solidconnection.auth.service.AuthTokenProvider; +import com.example.solidconnection.auth.service.TokenProvider; +import com.example.solidconnection.auth.service.TokenStorage; +import com.example.solidconnection.auth.token.TokenBlackListService; +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.fixture.SiteUserFixture; +import com.example.solidconnection.support.TestContainerSpringBootTest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +@DisplayName("어드민 인증 서비스 테스트") +@TestContainerSpringBootTest +class AdminAuthServiceTest { + + @Autowired + private AdminAuthService adminAuthService; + + @Autowired + private AuthTokenProvider authTokenProvider; + + @Autowired + private TokenProvider tokenProvider; + + @Autowired + private TokenStorage tokenStorage; + + @Autowired + private TokenBlackListService tokenBlackListService; + + @Autowired + private SiteUserFixture siteUserFixture; + + private SiteUser adminUser; + private SiteUser regularUser; + + @BeforeEach + void setUp() { + adminUser = siteUserFixture.관리자(); + regularUser = siteUserFixture.사용자(); + } + + @Nested + class 어드민_로그인 { + + @Test + void 어드민_사용자가_로그인하면_어드민_리프레시_토큰이_저장된다() { + // given + AdminSignInRequest request = new AdminSignInRequest("admin@example.com", "admin123"); + + // when + AdminSignInResult result = adminAuthService.signIn(request); + + // then + Subject subject = new Subject(adminUser.getId().toString()); + assertAll( + () -> assertThat(result.accessToken()).isNotNull(), + () -> assertThat(result.adminRefreshToken()).isNotNull(), + () -> assertThat(tokenStorage.findToken(subject, AdminRefreshToken.class)) + .hasValue(result.adminRefreshToken()) + ); + } + + @Test + void 어드민_권한이_없는_사용자가_로그인하면_예외가_발생한다() { + // given + AdminSignInRequest request = new AdminSignInRequest("test@example.com", "password123"); + + // when & then + assertThatCode(() -> adminAuthService.signIn(request)) + .isInstanceOf(CustomException.class) + .hasMessage(NOT_ADMIN_USER.getMessage()); + } + } + + @Nested + class 어드민_토큰_재발급 { + + @Test + void 저장된_어드민_리프레시_토큰으로_액세스_토큰을_재발급한다() { + // given + AdminRefreshToken adminRefreshToken = authTokenProvider.generateAndSaveAdminRefreshToken(adminUser); + + // when + AdminReissueResponse response = adminAuthService.reissue(adminRefreshToken.token()); + + // then - 재발급된 액세스 토큰과 어드민 리프레시 토큰의 주체가 동일해야 한다 + SiteUser tokenSiteUser = authTokenProvider.parseSiteUser(adminRefreshToken.token()); + SiteUser reissuedSiteUser = authTokenProvider.parseSiteUser(response.accessToken()); + assertThat(tokenSiteUser.getId()).isEqualTo(reissuedSiteUser.getId()); + } + + @Test + void 저장되지_않은_어드민_리프레시_토큰으로_재발급하면_예외가_발생한다() { + // given + AdminRefreshToken adminRefreshToken = authTokenProvider.generateAndSaveAdminRefreshToken(adminUser); + tokenStorage.deleteToken(new Subject(adminUser.getId().toString()), AdminRefreshToken.class); + + // when & then + assertThatCode(() -> adminAuthService.reissue(adminRefreshToken.token())) + .isInstanceOf(AuthException.class) + .hasMessage(ADMIN_REFRESH_TOKEN_EXPIRED.getMessage()); + } + } + + @Nested + class 어드민_로그아웃 { + + @Test + void 로그아웃하면_어드민_리프레시_토큰이_삭제되고_액세스_토큰이_블랙리스트에_추가된다() { + // given + String accessToken = authTokenProvider.generateAccessToken(adminUser).token(); + authTokenProvider.generateAndSaveAdminRefreshToken(adminUser); + Subject subject = new Subject(adminUser.getId().toString()); + + // when + adminAuthService.signOut(accessToken); + + // then + assertAll( + () -> assertThat(tokenStorage.findToken(subject, AdminRefreshToken.class)).isEmpty(), + () -> assertThat(tokenBlackListService.isTokenBlacklisted(accessToken)).isTrue() + ); + } + } +} diff --git a/src/test/java/com/example/solidconnection/auth/controller/RefreshTokenCookieManagerTest.java b/src/test/java/com/example/solidconnection/auth/controller/RefreshTokenCookieManagerTest.java index 62fa43a37..9a46138f8 100644 --- a/src/test/java/com/example/solidconnection/auth/controller/RefreshTokenCookieManagerTest.java +++ b/src/test/java/com/example/solidconnection/auth/controller/RefreshTokenCookieManagerTest.java @@ -61,7 +61,7 @@ void setUp() { () -> assertThat(header).contains("Path=/"), () -> assertThat(header).contains("Max-Age=" + tokenProperties.refresh().expireTime().toSeconds()), () -> assertThat(header).contains("Domain=" + domain), - () -> assertThat(header).contains("SameSite=" + SameSite.LAX.attributeValue()) + () -> assertThat(header).contains("SameSite=" + SameSite.STRICT.attributeValue()) ); } @@ -83,7 +83,7 @@ void setUp() { () -> assertThat(header).contains("Path=/"), () -> assertThat(header).contains("Max-Age=0"), () -> assertThat(header).contains("Domain=" + domain), - () -> assertThat(header).contains("SameSite=" + SameSite.LAX.attributeValue()) + () -> assertThat(header).contains("SameSite=" + SameSite.STRICT.attributeValue()) ); } diff --git a/src/test/java/com/example/solidconnection/community/post/service/UpdateViewCountServiceTest.java b/src/test/java/com/example/solidconnection/community/post/service/UpdateViewCountServiceTest.java new file mode 100644 index 000000000..651b15abf --- /dev/null +++ b/src/test/java/com/example/solidconnection/community/post/service/UpdateViewCountServiceTest.java @@ -0,0 +1,54 @@ +package com.example.solidconnection.community.post.service; + +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; + +import com.example.solidconnection.community.post.repository.PostRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@DisplayName("조회수 업데이트 서비스 테스트") +@ExtendWith(MockitoExtension.class) +class UpdateViewCountServiceTest { + + @InjectMocks + private UpdateViewCountService updateViewCountService; + + @Mock + private PostRepository postRepository; + + @Mock + private PostRedisManager postRedisManager; + + @Test + void Redis_조회수_키가_이미_소비되었으면_DB_업데이트를_하지_않는다() { + // given + String key = "post:view:1"; + given(postRedisManager.getPostIdFromPostViewCountRedisKey(key)).willReturn(1L); + given(postRedisManager.getAndDeleteViewCount(key)).willReturn(null); + + // when + updateViewCountService.updateViewCount(key); + + // then + then(postRepository).shouldHaveNoInteractions(); + } + + @Test + void Redis_조회수가_있으면_DB_조회수를_증가시킨다() { + // given + String key = "post:view:1"; + given(postRedisManager.getPostIdFromPostViewCountRedisKey(key)).willReturn(1L); + given(postRedisManager.getAndDeleteViewCount(key)).willReturn(2L); + + // when + updateViewCountService.updateViewCount(key); + + // then + then(postRepository).should().increaseViewCount(1L, 2L); + } +} diff --git a/src/test/java/com/example/solidconnection/siteuser/dto/SchoolEmailRequestTest.java b/src/test/java/com/example/solidconnection/siteuser/dto/SchoolEmailRequestTest.java new file mode 100644 index 000000000..674fa3deb --- /dev/null +++ b/src/test/java/com/example/solidconnection/siteuser/dto/SchoolEmailRequestTest.java @@ -0,0 +1,66 @@ +package com.example.solidconnection.siteuser.dto; + +import static org.assertj.core.api.Assertions.assertThat; + +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import jakarta.validation.ValidatorFactory; +import java.util.Set; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@DisplayName("학교 이메일 인증 요청 유효성 검사 테스트") +class SchoolEmailRequestTest { + + private static final String MESSAGE = "message"; + private static final String SCHOOL_EMAIL_MAX_LENGTH_MESSAGE = "학교 이메일은 100자 이하여야 합니다"; + + private Validator validator; + + @BeforeEach + void setUp() { + ValidatorFactory factory = Validation.buildDefaultValidatorFactory(); + validator = factory.getValidator(); + } + + @Nested + class 학교_이메일_길이_검증 { + + @Test + void 학교_이메일이_100자이면_검증을_통과한다() { + // given + SchoolEmailRequest request = new SchoolEmailRequest(emailWithLength(100)); + + // when + Set> violations = validator.validate(request); + + // then + assertThat(violations).isEmpty(); + } + + @Test + void 학교_이메일이_100자를_초과하면_검증에_실패한다() { + // given + SchoolEmailRequest request = new SchoolEmailRequest(emailWithLength(101)); + + // when + Set> violations = validator.validate(request); + + // then + assertThat(violations) + .extracting(MESSAGE) + .contains(SCHOOL_EMAIL_MAX_LENGTH_MESSAGE); + } + } + + private String emailWithLength(int length) { + String prefix = "a@"; + String domainPrefix = "b".repeat(63) + "."; + String suffix = ".edu"; + String domain = domainPrefix + "c".repeat(length - prefix.length() - domainPrefix.length() - suffix.length()); + return prefix + domain + suffix; + } +} diff --git a/src/test/java/com/example/solidconnection/siteuser/repository/SiteUserRepositoryTest.java b/src/test/java/com/example/solidconnection/siteuser/repository/SiteUserRepositoryTest.java index 41ec37251..7ea98622d 100644 --- a/src/test/java/com/example/solidconnection/siteuser/repository/SiteUserRepositoryTest.java +++ b/src/test/java/com/example/solidconnection/siteuser/repository/SiteUserRepositoryTest.java @@ -88,4 +88,22 @@ class 닉네임은_중복될_수_없다 { .isInstanceOf(DataIntegrityViolationException.class); } } + + @Nested + class 인증된_학교_이메일은_중복될_수_없다 { + + @Test + void 인증된_학교_이메일이_동일한_사용자를_저장하면_예외가_발생한다() { + // given + SiteUser user1 = createSiteUser("email1", "nickname1", AuthType.KAKAO); + SiteUser user2 = createSiteUser("email2", "nickname2", AuthType.KAKAO); + user1.verifySchool(1L, "test@inha.edu"); + user2.verifySchool(1L, "test@inha.edu"); + siteUserRepository.save(user1); + + // when, then + assertThatCode(() -> siteUserRepository.saveAndFlush(user2)) + .isInstanceOf(DataIntegrityViolationException.class); + } + } } diff --git a/src/test/java/com/example/solidconnection/siteuser/service/SchoolEmailServiceTest.java b/src/test/java/com/example/solidconnection/siteuser/service/SchoolEmailServiceTest.java index 2cf4de360..3fb436be1 100644 --- a/src/test/java/com/example/solidconnection/siteuser/service/SchoolEmailServiceTest.java +++ b/src/test/java/com/example/solidconnection/siteuser/service/SchoolEmailServiceTest.java @@ -1,14 +1,17 @@ package com.example.solidconnection.siteuser.service; import static com.example.solidconnection.common.exception.ErrorCode.SCHOOL_EMAIL_ALREADY_VERIFIED; +import static com.example.solidconnection.common.exception.ErrorCode.SCHOOL_EMAIL_ALREADY_USED; import static com.example.solidconnection.common.exception.ErrorCode.SCHOOL_EMAIL_CONFIRM_CODE_DIFFERENT; import static com.example.solidconnection.common.exception.ErrorCode.SCHOOL_EMAIL_CONFIRM_REQUEST_NOT_FOUND; import static com.example.solidconnection.common.exception.ErrorCode.SCHOOL_EMAIL_DOMAIN_NOT_SUPPORTED; +import static com.example.solidconnection.common.exception.ErrorCode.SCHOOL_EMAIL_VERIFICATION_INFO_CORRUPTED; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.times; import com.example.solidconnection.common.exception.CustomException; import com.example.solidconnection.common.mail.MailService; @@ -18,11 +21,13 @@ import com.example.solidconnection.support.TestContainerSpringBootTest; import com.example.solidconnection.university.domain.HomeUniversity; import com.example.solidconnection.university.fixture.HomeUniversityFixture; +import java.util.List; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.redis.core.RedisTemplate; import org.springframework.test.context.bean.override.mockito.MockitoBean; @TestContainerSpringBootTest @@ -44,6 +49,9 @@ class SchoolEmailServiceTest { @Autowired private SiteUserRepository siteUserRepository; + @Autowired + private RedisTemplate redisTemplate; + @Nested @DisplayName("학교 이메일 인증 요청") class 학교_이메일_인증_요청 { @@ -55,7 +63,7 @@ class 학교_이메일_인증_요청 { SiteUser siteUser = siteUserFixture.사용자(); // when & then - schoolEmailService.requestSchoolEmailVerification(siteUser.getId(), "test@inha.edu"); + schoolEmailService.requestSchoolEmailVerification(siteUser.getId(), " Test@INHA.EDU "); then(mailService).should().sendVerificationEmail(eq("test@inha.edu"), any()); } @@ -72,6 +80,25 @@ class 학교_이메일_인증_요청 { .hasMessage(SCHOOL_EMAIL_ALREADY_VERIFIED.getMessage()); } + @Test + void 이미_인증에_사용된_학교_이메일이면_예외가_발생한다() { + // given + homeUniversityFixture.인하대학교(); + SiteUser verifiedUser = siteUserFixture.사용자(1, "인증사용자"); + SiteUser anotherUser = siteUserFixture.사용자(2, "다른사용자"); + schoolEmailService.requestSchoolEmailVerification(verifiedUser.getId(), "test@inha.edu"); + + ArgumentCaptor codeCaptor = ArgumentCaptor.forClass(String.class); + then(mailService).should().sendVerificationEmail(eq("test@inha.edu"), codeCaptor.capture()); + schoolEmailService.confirmSchoolEmail(verifiedUser.getId(), codeCaptor.getValue()); + + // when & then + assertThatThrownBy(() -> + schoolEmailService.requestSchoolEmailVerification(anotherUser.getId(), "test@inha.edu")) + .isInstanceOf(CustomException.class) + .hasMessage(SCHOOL_EMAIL_ALREADY_USED.getMessage()); + } + @Test void 지원하지_않는_이메일_도메인은_예외가_발생한다() { // given @@ -94,7 +121,7 @@ class 학교_이메일_인증_확인 { // given HomeUniversity homeUniversity = homeUniversityFixture.인하대학교(); SiteUser siteUser = siteUserFixture.사용자(); - schoolEmailService.requestSchoolEmailVerification(siteUser.getId(), "test@inha.edu"); + schoolEmailService.requestSchoolEmailVerification(siteUser.getId(), " Test@INHA.EDU "); ArgumentCaptor codeCaptor = ArgumentCaptor.forClass(String.class); then(mailService).should().sendVerificationEmail(any(), codeCaptor.capture()); @@ -106,6 +133,7 @@ class 학교_이메일_인증_확인 { // Then SiteUser updated = siteUserRepository.findById(siteUser.getId()).orElseThrow(); assertThat(updated.getHomeUniversityId()).isEqualTo(homeUniversity.getId()); + assertThat(updated.getVerifiedSchoolEmail()).isEqualTo("test@inha.edu"); } @Test @@ -133,5 +161,45 @@ class 학교_이메일_인증_확인 { .isInstanceOf(CustomException.class) .hasMessage(SCHOOL_EMAIL_CONFIRM_CODE_DIFFERENT.getMessage()); } + + @Test + void 인증_정보가_손상되면_예외가_발생하고_인증_정보가_삭제된다() { + // given + HomeUniversity homeUniversity = homeUniversityFixture.인하대학교(); + SiteUser siteUser = siteUserFixture.사용자(); + String key = "school-email:" + siteUser.getId(); + redisTemplate.opsForValue().set( + key, + "{\"homeUniversityId\":" + homeUniversity.getId() + ",\"code\":\"123456\"}" + ); + + // when & then + assertThatThrownBy(() -> + schoolEmailService.confirmSchoolEmail(siteUser.getId(), "123456")) + .isInstanceOf(CustomException.class) + .hasMessage(SCHOOL_EMAIL_VERIFICATION_INFO_CORRUPTED.getMessage()); + assertThat(redisTemplate.opsForValue().get(key)).isNull(); + } + + @Test + void 같은_학교_이메일로_인증_코드를_받았더라도_먼저_인증한_사용자가_있으면_나중_인증은_예외가_발생한다() { + // given + homeUniversityFixture.인하대학교(); + SiteUser firstUser = siteUserFixture.사용자(1, "첫번째사용자"); + SiteUser secondUser = siteUserFixture.사용자(2, "두번째사용자"); + schoolEmailService.requestSchoolEmailVerification(firstUser.getId(), "test@inha.edu"); + schoolEmailService.requestSchoolEmailVerification(secondUser.getId(), "test@inha.edu"); + + ArgumentCaptor codeCaptor = ArgumentCaptor.forClass(String.class); + then(mailService).should(times(2)).sendVerificationEmail(eq("test@inha.edu"), codeCaptor.capture()); + List codes = codeCaptor.getAllValues(); + schoolEmailService.confirmSchoolEmail(firstUser.getId(), codes.get(0)); + + // when & then + assertThatThrownBy(() -> + schoolEmailService.confirmSchoolEmail(secondUser.getId(), codes.get(1))) + .isInstanceOf(CustomException.class) + .hasMessage(SCHOOL_EMAIL_ALREADY_USED.getMessage()); + } } } diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index 8fc126ba9..bba4cedbf 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -97,6 +97,10 @@ token: cookie-domain: "test.domain.com" storage-key-prefix: "REFRESH" expire-time: 10m + admin-refresh: + cookie-domain: "test.domain.com" + storage-key-prefix: "ADMIN_REFRESH" + expire-time: 10m sign-up: storage-key-prefix: "SIGN_UP" expire-time: 10m