diff --git a/build.gradle b/build.gradle index c581c0d..bbc5b33 100644 --- a/build.gradle +++ b/build.gradle @@ -51,6 +51,18 @@ dependencies { implementation 'io.micrometer:micrometer-core' implementation 'io.micrometer:micrometer-registry-prometheus' + // Spring Security + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.security:spring-security-test' + + // JWT + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' + + // Redis + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'com.h2database:h2' diff --git a/src/main/java/com/doubleo/adminservice/domain/admin/domain/Admin.java b/src/main/java/com/doubleo/adminservice/domain/admin/domain/Admin.java index f433f6e..8721aaa 100644 --- a/src/main/java/com/doubleo/adminservice/domain/admin/domain/Admin.java +++ b/src/main/java/com/doubleo/adminservice/domain/admin/domain/Admin.java @@ -11,28 +11,42 @@ @Getter @NoArgsConstructor public class Admin extends BaseTimeEntity { + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "admin_id") + @Column(name = "admin_id", nullable = false) private Long id; - @Column(name = "admin_company") - private String company; - - @Column(name = "admin_account_id") - private String accountId; // 정규화 추가 + @Column(name = "admin_username", nullable = false, unique = true) + private String username; - @Column(name = "admin_password") + @Column(name = "admin_password", nullable = false, length = 100) private String password; + @Column(name = "admin_name", nullable = false) + private String name; + + @Column(name = "admin_contact", nullable = false) + private String contact; + @Builder(access = AccessLevel.PRIVATE) - private Admin(String company, String accountId, String password) { - this.company = company; - this.accountId = accountId; + private Admin(String username, String password, String name, String contact) { + this.username = username; this.password = password; + this.name = name; + this.contact = contact; + } + + public static Admin createAdmin(String username, String password, String name, String contact) { + return Admin.builder() + .username(username) + .password(password) + .name(name) + .contact(contact) + .build(); } - public static Admin createAdmin(String company, String accountId, String password) { - return Admin.builder().company(company).accountId(accountId).password(password).build(); + public void updateAdminPassword(String passwordNew) { + this.password = passwordNew; } } diff --git a/src/main/java/com/doubleo/adminservice/domain/admin/repository/AdminRepository.java b/src/main/java/com/doubleo/adminservice/domain/admin/repository/AdminRepository.java new file mode 100644 index 0000000..aa10729 --- /dev/null +++ b/src/main/java/com/doubleo/adminservice/domain/admin/repository/AdminRepository.java @@ -0,0 +1,11 @@ +package com.doubleo.adminservice.domain.admin.repository; + +import com.doubleo.adminservice.domain.admin.domain.Admin; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface AdminRepository extends JpaRepository { + Optional findAdminByUsername(String username); +} diff --git a/src/main/java/com/doubleo/adminservice/domain/auth/controller/AuthController.java b/src/main/java/com/doubleo/adminservice/domain/auth/controller/AuthController.java new file mode 100644 index 0000000..a8e7d67 --- /dev/null +++ b/src/main/java/com/doubleo/adminservice/domain/auth/controller/AuthController.java @@ -0,0 +1,97 @@ +package com.doubleo.adminservice.domain.auth.controller; + +import com.doubleo.adminservice.domain.auth.dto.AccessTokenDto; +import com.doubleo.adminservice.domain.auth.dto.RefreshTokenDto; +import com.doubleo.adminservice.domain.auth.dto.request.LoginRequest; +import com.doubleo.adminservice.domain.auth.dto.response.LoginResponse; +import com.doubleo.adminservice.domain.auth.service.AuthService; +import com.doubleo.adminservice.domain.auth.service.JwtTokenService; +import com.doubleo.adminservice.global.util.CookieUtil; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseCookie; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.util.WebUtils; + +@Tag(name = "1-2. Auth API", description = "회원 로그인/로그아웃/Refresh Token 관련 API") +@RestController +@RequiredArgsConstructor +@RequestMapping("/auth") +public class AuthController { + + private final AuthService authService; + private final CookieUtil cookieUtil; + private final JwtTokenService jwtTokenService; + + @Operation(summary = "회원 로그인", description = "회원 로그인을 처리합니다.") + @PostMapping("/login") + public ResponseEntity adminLogin(@RequestBody LoginRequest request) { + LoginResponse response = authService.loginAdmin(request); + String refreshToken = response.refreshToken(); + HttpHeaders headers = cookieUtil.generateRefreshTokenCookie(refreshToken); + + return ResponseEntity.ok().headers(headers).body(response); + } + + @Operation(summary = "회원 로그아웃", description = "회원 로그아웃을 처리합니다.") + @PostMapping("/logout") + public ResponseEntity adminLogout( + @RequestHeader(HttpHeaders.AUTHORIZATION) String authorizationHeader, + @RequestHeader("X-Admin-Id") Long adminId, + HttpServletResponse response) { + authService.logoutAdmin(authorizationHeader, adminId); + + ResponseCookie clearCookie = + ResponseCookie.from("refreshToken", "") + .httpOnly(true) + .secure(true) + .path("/") + .maxAge(0) + .sameSite("Strict") + .build(); + response.addHeader(HttpHeaders.SET_COOKIE, clearCookie.toString()); + + return ResponseEntity.noContent().build(); + } + + @Operation( + summary = "Access Token 재발급", + description = "유효한 RefreshToken 을 통해 AccessToken 을 재발급합니다.") + @PostMapping("/reissue") + public ResponseEntity tokenReissue( + HttpServletRequest request, HttpServletResponse response) { + String oldAccessToken = extractAccessTokenFromHeader(request); + String refreshToken = extractRefreshTokenFromCookie(request); + + RefreshTokenDto refreshTokenDto = jwtTokenService.retrieveRefreshToken(refreshToken); + if (refreshTokenDto == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + AccessTokenDto newAccessTokenDto = + jwtTokenService.reissueAccessTokenIfExpired(oldAccessToken); + response.addHeader( + HttpHeaders.AUTHORIZATION, "Bearer " + newAccessTokenDto.accessTokenValue()); + + return ResponseEntity.ok().build(); + } + + private String extractRefreshTokenFromCookie(HttpServletRequest request) { + Cookie cookie = WebUtils.getCookie(request, "refreshToken"); + return (cookie != null) ? cookie.getValue() : null; + } + + private String extractAccessTokenFromHeader(HttpServletRequest request) { + String authorizationHeader = request.getHeader(HttpHeaders.AUTHORIZATION); + if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) { + return authorizationHeader.substring(7); + } + return null; + } +} diff --git a/src/main/java/com/doubleo/adminservice/domain/auth/domain/BlackListToken.java b/src/main/java/com/doubleo/adminservice/domain/auth/domain/BlackListToken.java new file mode 100644 index 0000000..b9edf25 --- /dev/null +++ b/src/main/java/com/doubleo/adminservice/domain/auth/domain/BlackListToken.java @@ -0,0 +1,27 @@ +package com.doubleo.adminservice.domain.auth.domain; + +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import org.springframework.data.annotation.Id; +import org.springframework.data.redis.core.RedisHash; +import org.springframework.data.redis.core.TimeToLive; + +@Getter +@RedisHash("blacklistToken") +public class BlackListToken { + + @Id private final String token; + + @TimeToLive private final long ttl; + + @Builder(access = AccessLevel.PRIVATE) + private BlackListToken(String token, long ttl) { + this.token = token; + this.ttl = ttl; + } + + public static BlackListToken createBlackListToken(String token, Long ttl) { + return builder().token(token).ttl(ttl).build(); + } +} diff --git a/src/main/java/com/doubleo/adminservice/domain/auth/domain/RefreshToken.java b/src/main/java/com/doubleo/adminservice/domain/auth/domain/RefreshToken.java new file mode 100644 index 0000000..d99030a --- /dev/null +++ b/src/main/java/com/doubleo/adminservice/domain/auth/domain/RefreshToken.java @@ -0,0 +1,25 @@ +package com.doubleo.adminservice.domain.auth.domain; + +import lombok.Builder; +import lombok.Getter; +import org.springframework.data.annotation.Id; +import org.springframework.data.redis.core.RedisHash; +import org.springframework.data.redis.core.TimeToLive; + +@Getter +@RedisHash(value = "refreshToken") +public class RefreshToken { + + @Id private final Long adminId; + + private final String token; + + @TimeToLive private final long ttl; + + @Builder + private RefreshToken(Long adminId, String token, long ttl) { + this.adminId = adminId; + this.token = token; + this.ttl = ttl; + } +} diff --git a/src/main/java/com/doubleo/adminservice/domain/auth/dto/AccessTokenDto.java b/src/main/java/com/doubleo/adminservice/domain/auth/dto/AccessTokenDto.java new file mode 100644 index 0000000..2ab095e --- /dev/null +++ b/src/main/java/com/doubleo/adminservice/domain/auth/dto/AccessTokenDto.java @@ -0,0 +1,7 @@ +package com.doubleo.adminservice.domain.auth.dto; + +public record AccessTokenDto(Long adminId, String accessTokenValue) { + public static AccessTokenDto of(Long adminId, String accessTokenValue) { + return new AccessTokenDto(adminId, accessTokenValue); + } +} diff --git a/src/main/java/com/doubleo/adminservice/domain/auth/dto/RefreshTokenDto.java b/src/main/java/com/doubleo/adminservice/domain/auth/dto/RefreshTokenDto.java new file mode 100644 index 0000000..446f43a --- /dev/null +++ b/src/main/java/com/doubleo/adminservice/domain/auth/dto/RefreshTokenDto.java @@ -0,0 +1,7 @@ +package com.doubleo.adminservice.domain.auth.dto; + +public record RefreshTokenDto(Long adminId, String refreshTokenValue, Long ttl) { + public static RefreshTokenDto of(Long adminId, String refreshTokenValue, Long ttl) { + return new RefreshTokenDto(adminId, refreshTokenValue, ttl); + } +} diff --git a/src/main/java/com/doubleo/adminservice/domain/auth/dto/request/LoginRequest.java b/src/main/java/com/doubleo/adminservice/domain/auth/dto/request/LoginRequest.java new file mode 100644 index 0000000..52e741f --- /dev/null +++ b/src/main/java/com/doubleo/adminservice/domain/auth/dto/request/LoginRequest.java @@ -0,0 +1,7 @@ +package com.doubleo.adminservice.domain.auth.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record LoginRequest( + @Schema(description = "관리자 아이디", example = "username") String username, + @Schema(description = "관리자 패스워드", example = "pw12345") String password) {} diff --git a/src/main/java/com/doubleo/adminservice/domain/auth/dto/response/LoginResponse.java b/src/main/java/com/doubleo/adminservice/domain/auth/dto/response/LoginResponse.java new file mode 100644 index 0000000..426f73a --- /dev/null +++ b/src/main/java/com/doubleo/adminservice/domain/auth/dto/response/LoginResponse.java @@ -0,0 +1,9 @@ +package com.doubleo.adminservice.domain.auth.dto.response; + +import com.fasterxml.jackson.annotation.JsonIgnore; + +public record LoginResponse(String accessToken, @JsonIgnore String refreshToken) { + public static LoginResponse of(String accessToken, String refreshToken) { + return new LoginResponse(accessToken, refreshToken); + } +} diff --git a/src/main/java/com/doubleo/adminservice/domain/auth/repository/BlackListTokenRepository.java b/src/main/java/com/doubleo/adminservice/domain/auth/repository/BlackListTokenRepository.java new file mode 100644 index 0000000..64ce393 --- /dev/null +++ b/src/main/java/com/doubleo/adminservice/domain/auth/repository/BlackListTokenRepository.java @@ -0,0 +1,6 @@ +package com.doubleo.adminservice.domain.auth.repository; + +import com.doubleo.adminservice.domain.auth.domain.BlackListToken; +import org.springframework.data.repository.CrudRepository; + +public interface BlackListTokenRepository extends CrudRepository {} diff --git a/src/main/java/com/doubleo/adminservice/domain/auth/repository/RefreshTokenRepository.java b/src/main/java/com/doubleo/adminservice/domain/auth/repository/RefreshTokenRepository.java new file mode 100644 index 0000000..ac4b56d --- /dev/null +++ b/src/main/java/com/doubleo/adminservice/domain/auth/repository/RefreshTokenRepository.java @@ -0,0 +1,6 @@ +package com.doubleo.adminservice.domain.auth.repository; + +import com.doubleo.adminservice.domain.auth.domain.RefreshToken; +import org.springframework.data.repository.CrudRepository; + +public interface RefreshTokenRepository extends CrudRepository {} diff --git a/src/main/java/com/doubleo/adminservice/domain/auth/service/AuthService.java b/src/main/java/com/doubleo/adminservice/domain/auth/service/AuthService.java new file mode 100644 index 0000000..3dfeb39 --- /dev/null +++ b/src/main/java/com/doubleo/adminservice/domain/auth/service/AuthService.java @@ -0,0 +1,11 @@ +package com.doubleo.adminservice.domain.auth.service; + +import com.doubleo.adminservice.domain.auth.dto.request.LoginRequest; +import com.doubleo.adminservice.domain.auth.dto.response.LoginResponse; + +public interface AuthService { + + LoginResponse loginAdmin(LoginRequest request); + + void logoutAdmin(String accessTokenValue, Long adminId); +} diff --git a/src/main/java/com/doubleo/adminservice/domain/auth/service/AuthServiceImpl.java b/src/main/java/com/doubleo/adminservice/domain/auth/service/AuthServiceImpl.java new file mode 100644 index 0000000..bc20c2b --- /dev/null +++ b/src/main/java/com/doubleo/adminservice/domain/auth/service/AuthServiceImpl.java @@ -0,0 +1,54 @@ +package com.doubleo.adminservice.domain.auth.service; + +import com.doubleo.adminservice.domain.admin.domain.Admin; +import com.doubleo.adminservice.domain.admin.repository.AdminRepository; +import com.doubleo.adminservice.domain.auth.dto.request.LoginRequest; +import com.doubleo.adminservice.domain.auth.dto.response.LoginResponse; +import com.doubleo.adminservice.domain.auth.repository.RefreshTokenRepository; +import com.doubleo.adminservice.global.exception.CommonException; +import com.doubleo.adminservice.global.exception.errorcode.AdminErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class AuthServiceImpl implements AuthService { + + private final AdminRepository adminRepository; + private final RefreshTokenRepository refreshTokenRepository; + private final JwtTokenService jwtTokenService; + private final BCryptPasswordEncoder encoder; + + public LoginResponse loginAdmin(LoginRequest request) { + Admin admin = validateAdminByEmail(request.username()); + if (!encoder.matches(request.password(), admin.getPassword())) { + throw new CommonException(AdminErrorCode.ADMIN_NOT_FOUND); + } + return getLoginResponse(admin); + } + + public void logoutAdmin(String accessTokenValue, Long adminId) { + validateAdminById(adminId); + refreshTokenRepository.deleteById(adminId); + jwtTokenService.putAccessTokenOnBlackList(accessTokenValue); + } + + private Admin validateAdminByEmail(String email) { + return adminRepository + .findAdminByUsername(email) + .orElseThrow(() -> new CommonException(AdminErrorCode.ADMIN_NOT_FOUND)); + } + + private void validateAdminById(Long adminId) { + adminRepository + .findById(adminId) + .orElseThrow(() -> new CommonException(AdminErrorCode.ADMIN_NOT_FOUND)); + } + + private LoginResponse getLoginResponse(Admin admin) { + String accessToken = jwtTokenService.createAccessToken(admin.getId()); + String refreshToken = jwtTokenService.createRefreshToken(admin.getId()); + return LoginResponse.of(accessToken, refreshToken); + } +} diff --git a/src/main/java/com/doubleo/adminservice/domain/auth/service/JwtTokenService.java b/src/main/java/com/doubleo/adminservice/domain/auth/service/JwtTokenService.java new file mode 100644 index 0000000..3ac5888 --- /dev/null +++ b/src/main/java/com/doubleo/adminservice/domain/auth/service/JwtTokenService.java @@ -0,0 +1,25 @@ +package com.doubleo.adminservice.domain.auth.service; + +import com.doubleo.adminservice.domain.auth.dto.AccessTokenDto; +import com.doubleo.adminservice.domain.auth.dto.RefreshTokenDto; + +public interface JwtTokenService { + + // AccessToken DTO 생성 + AccessTokenDto createAccessTokenDto(Long adminId); + + // AccessToken 생성 + String createAccessToken(Long adminId); + + // RefreshToken 생성 + String createRefreshToken(Long adminId); + + // DB 저장된 RefreshToken 조회 및 검증 + RefreshTokenDto retrieveRefreshToken(String refreshTokenValue); + + // AccessToken 만료 여부 검증 후 재발급 + AccessTokenDto reissueAccessTokenIfExpired(String accessTokenValue); + + // 사용하지 않는 AccessToken BlackList 등록 + void putAccessTokenOnBlackList(String accessTokenValue); +} diff --git a/src/main/java/com/doubleo/adminservice/domain/auth/service/JwtTokenServiceImpl.java b/src/main/java/com/doubleo/adminservice/domain/auth/service/JwtTokenServiceImpl.java new file mode 100644 index 0000000..01e09cf --- /dev/null +++ b/src/main/java/com/doubleo/adminservice/domain/auth/service/JwtTokenServiceImpl.java @@ -0,0 +1,97 @@ +package com.doubleo.adminservice.domain.auth.service; + +import com.doubleo.adminservice.domain.auth.domain.BlackListToken; +import com.doubleo.adminservice.domain.auth.domain.RefreshToken; +import com.doubleo.adminservice.domain.auth.dto.AccessTokenDto; +import com.doubleo.adminservice.domain.auth.dto.RefreshTokenDto; +import com.doubleo.adminservice.domain.auth.repository.BlackListTokenRepository; +import com.doubleo.adminservice.domain.auth.repository.RefreshTokenRepository; +import com.doubleo.adminservice.global.util.JwtUtil; +import io.jsonwebtoken.ExpiredJwtException; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class JwtTokenServiceImpl implements JwtTokenService { + + private final JwtUtil jwtUtil; + private final RefreshTokenRepository refreshTokenRepository; + private final BlackListTokenRepository blackListTokenRepository; + + public AccessTokenDto createAccessTokenDto(Long adminId) { + return jwtUtil.generateAccessTokenDto(adminId); + } + + public String createAccessToken(Long adminId) { + return jwtUtil.generateAccessToken(adminId); + } + + public String createRefreshToken(Long adminId) { + String token = jwtUtil.generateRefreshToken(adminId); + RefreshToken refreshToken = + RefreshToken.builder() + .adminId(adminId) + .token(token) + .ttl(jwtUtil.getRefreshTokenExpirationTime()) + .build(); + refreshTokenRepository.save(refreshToken); + + return token; + } + + public RefreshTokenDto retrieveRefreshToken(String refreshTokenValue) { + RefreshTokenDto refreshTokenDto = parseRefreshToken(refreshTokenValue); + + if (refreshTokenDto == null) { + return null; + } + + Optional refreshToken = getRefreshToken(refreshTokenDto.adminId()); + + if (refreshToken.isPresent() + && refreshTokenDto.refreshTokenValue().equals(refreshToken.get().getToken())) { + return refreshTokenDto; + } + + return null; + } + + public AccessTokenDto reissueAccessTokenIfExpired(String accessTokenValue) { + try { + jwtUtil.parseAccessToken(accessTokenValue); + return null; + } catch (ExpiredJwtException e) { + Long adminId = Long.parseLong(e.getClaims().getSubject()); + + return createAccessTokenDto(adminId); + } + } + + public void putAccessTokenOnBlackList(String accessTokenValue) { + + String accessToken = jwtUtil.resolveToken(accessTokenValue); + if (accessToken == null) { + return; + } + + long remainingMs = jwtUtil.getRemainingExpirationMillis(accessToken); + long ttlSeconds = remainingMs > 0 ? remainingMs / 1000 : 0; + + BlackListToken black = BlackListToken.createBlackListToken(accessToken, ttlSeconds); + blackListTokenRepository.save(black); + } + + private RefreshTokenDto parseRefreshToken(String refreshTokenValue) { + try { + return jwtUtil.parseRefreshToken(refreshTokenValue); + } catch (Exception e) { + return null; + } + } + + private Optional getRefreshToken(Long adminId) { + return refreshTokenRepository.findById(adminId); + } +} diff --git a/src/main/java/com/doubleo/adminservice/global/config/security/WebSecurityConfig.java b/src/main/java/com/doubleo/adminservice/global/config/security/WebSecurityConfig.java new file mode 100644 index 0000000..d3df86d --- /dev/null +++ b/src/main/java/com/doubleo/adminservice/global/config/security/WebSecurityConfig.java @@ -0,0 +1,36 @@ +package com.doubleo.adminservice.global.config.security; + +import static org.springframework.security.config.Customizer.withDefaults; + +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; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class WebSecurityConfig { + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http.csrf(AbstractHttpConfigurer::disable) + .cors(withDefaults()) + .httpBasic(AbstractHttpConfigurer::disable) + .formLogin(AbstractHttpConfigurer::disable) + .sessionManagement( + session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); + http.authorizeHttpRequests(auth -> auth.anyRequest().permitAll()); + return http.build(); + } + + @Bean + public BCryptPasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/src/main/java/com/doubleo/adminservice/global/exception/errorcode/AdminErrorCode.java b/src/main/java/com/doubleo/adminservice/global/exception/errorcode/AdminErrorCode.java index 6082d64..1550273 100644 --- a/src/main/java/com/doubleo/adminservice/global/exception/errorcode/AdminErrorCode.java +++ b/src/main/java/com/doubleo/adminservice/global/exception/errorcode/AdminErrorCode.java @@ -8,7 +8,9 @@ @AllArgsConstructor public enum AdminErrorCode implements BaseErrorCode { ADMIN_NOT_FOUND(HttpStatus.NOT_FOUND, "관리자 정보를 찾을 수 없습니다."), - ; + ADMIN_ALREADY_EXISTS(HttpStatus.CONFLICT, "이미 존재하는 관리자입니다."), + INVALID_PASSWORD(HttpStatus.BAD_REQUEST, "유효한 비밀번호가 아닙니다."), + DUPLICATED_PASSWORD(HttpStatus.BAD_REQUEST, "변경되는 비밀번호는 기존 비밀번호와 동일할 수 없습니다."); private final HttpStatus httpStatus; private final String message; diff --git a/src/main/java/com/doubleo/adminservice/global/util/CookieUtil.java b/src/main/java/com/doubleo/adminservice/global/util/CookieUtil.java new file mode 100644 index 0000000..13263a2 --- /dev/null +++ b/src/main/java/com/doubleo/adminservice/global/util/CookieUtil.java @@ -0,0 +1,41 @@ +package com.doubleo.adminservice.global.util; + +import org.springframework.boot.web.server.Cookie; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseCookie; +import org.springframework.stereotype.Component; + +@Component +public class CookieUtil { + + public HttpHeaders generateRefreshTokenCookie(String refreshToken) { + ResponseCookie refreshTokenCookie = + ResponseCookie.from("refreshToken", refreshToken) + .path("/") + .secure(false) + .sameSite(Cookie.SameSite.NONE.attributeValue()) + .httpOnly(true) + .build(); + + HttpHeaders headers = new HttpHeaders(); + headers.add(HttpHeaders.SET_COOKIE, refreshTokenCookie.toString()); + + return headers; + } + + public HttpHeaders deleteRefreshTokenCookie() { + ResponseCookie refreshTokenCookie = + ResponseCookie.from("refreshToken", "") + .path("/") + .maxAge(0) + .secure(false) + .sameSite(Cookie.SameSite.NONE.attributeValue()) + .httpOnly(true) + .build(); + + HttpHeaders headers = new HttpHeaders(); + headers.add(HttpHeaders.SET_COOKIE, refreshTokenCookie.toString()); + + return headers; + } +} diff --git a/src/main/java/com/doubleo/adminservice/global/util/JwtUtil.java b/src/main/java/com/doubleo/adminservice/global/util/JwtUtil.java new file mode 100644 index 0000000..aad7457 --- /dev/null +++ b/src/main/java/com/doubleo/adminservice/global/util/JwtUtil.java @@ -0,0 +1,134 @@ +package com.doubleo.adminservice.global.util; + +import com.doubleo.adminservice.domain.auth.dto.AccessTokenDto; +import com.doubleo.adminservice.domain.auth.dto.RefreshTokenDto; +import com.doubleo.adminservice.infra.config.jwt.JwtProperties; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jws; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import java.security.Key; +import java.util.Date; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class JwtUtil { + + private final JwtProperties jwtProperties; + + public AccessTokenDto generateAccessTokenDto(Long adminId) { + Date issuedAt = new Date(); + Date expiredAt = + new Date(issuedAt.getTime() + jwtProperties.accessTokenExpirationMilliTime()); + String tokenValue = buildAccessToken(adminId, issuedAt, expiredAt); + return new AccessTokenDto(adminId, tokenValue); + } + + public String generateAccessToken(Long adminId) { + Date issuedAt = new Date(); + Date expiredAt = + new Date(issuedAt.getTime() + jwtProperties.accessTokenExpirationMilliTime()); + return buildAccessToken(adminId, issuedAt, expiredAt); + } + + // public RefreshTokenDto generateRefreshTokenDto(Long adminId) { + // Date issuedAt = new Date(); + // Date expiredAt = + // new Date(issuedAt.getTime() + + // jwtProperties.refreshTokenExpirationMilliTime()); + // String tokenValue = buildRefreshToken(adminId, issuedAt, expiredAt); + // return new RefreshTokenDto( + // adminId, tokenValue, jwtProperties.refreshTokenExpirationTime()); + // } + + public String resolveToken(String headerValue) { + if (headerValue != null && headerValue.startsWith("Bearer ")) { + return headerValue.substring(7); + } + return null; + } + + public long getRemainingExpirationMillis(String tokenValue) { + Jws claims = getClaims(tokenValue, getAccessTokenKey()); + Date exp = claims.getBody().getExpiration(); + return Math.max(exp.getTime() - System.currentTimeMillis(), 0); + } + + public AccessTokenDto parseAccessToken(String accessTokenValue) throws ExpiredJwtException { + try { + Jws claims = getClaims(accessTokenValue, getAccessTokenKey()); + + return AccessTokenDto.of( + Long.parseLong(claims.getBody().getSubject()), accessTokenValue); + } catch (ExpiredJwtException e) { + throw e; + } catch (Exception e) { + return null; + } + } + + public RefreshTokenDto parseRefreshToken(String refreshTokenValue) throws ExpiredJwtException { + try { + Jws claims = getClaims(refreshTokenValue, getRefreshTokenKey()); + + return RefreshTokenDto.of( + Long.parseLong(claims.getBody().getSubject()), + refreshTokenValue, + jwtProperties.refreshTokenExpirationTime()); + } catch (ExpiredJwtException e) { + throw e; + } catch (Exception e) { + return null; + } + } + + public String generateRefreshToken(Long adminId) { + Date issuedAt = new Date(); + Date expiredAt = + new Date(issuedAt.getTime() + jwtProperties.refreshTokenExpirationMilliTime()); + return buildRefreshToken(adminId, issuedAt, expiredAt); + } + + public long getRefreshTokenExpirationTime() { + return jwtProperties.refreshTokenExpirationTime(); + } + + private Key getAccessTokenKey() { + return Keys.hmacShaKeyFor(jwtProperties.accessTokenSecret().getBytes()); + } + + private Key getRefreshTokenKey() { + return Keys.hmacShaKeyFor(jwtProperties.refreshTokenSecret().getBytes()); + } + + private String buildAccessToken(Long adminId, Date issuedAt, Date expiredAt) { + return Jwts.builder() + .setIssuer(jwtProperties.issuer()) + .setSubject(adminId.toString()) + .setIssuedAt(issuedAt) + .setExpiration(expiredAt) + .signWith(getAccessTokenKey()) + .compact(); + } + + private String buildRefreshToken(Long adminId, Date issuedAt, Date expiredAt) { + return Jwts.builder() + .setIssuer(jwtProperties.issuer()) + .setSubject(adminId.toString()) + .setIssuedAt(issuedAt) + .setExpiration(expiredAt) + .signWith(getRefreshTokenKey()) + .compact(); + } + + private Jws getClaims(String token, Key key) { + return Jwts.parserBuilder() + .requireIssuer(jwtProperties.issuer()) + .setSigningKey(key) + .build() + .parseClaimsJws(token); + } +} diff --git a/src/main/java/com/doubleo/adminservice/infra/config/jwt/JwtProperties.java b/src/main/java/com/doubleo/adminservice/infra/config/jwt/JwtProperties.java new file mode 100644 index 0000000..4b92773 --- /dev/null +++ b/src/main/java/com/doubleo/adminservice/infra/config/jwt/JwtProperties.java @@ -0,0 +1,19 @@ +package com.doubleo.adminservice.infra.config.jwt; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "jwt") +public record JwtProperties( + String accessTokenSecret, + String refreshTokenSecret, + Long accessTokenExpirationTime, + Long refreshTokenExpirationTime, + String issuer) { + public Long accessTokenExpirationMilliTime() { + return accessTokenExpirationTime * 1000; + } + + public Long refreshTokenExpirationMilliTime() { + return refreshTokenExpirationTime * 1000; + } +} diff --git a/src/main/java/com/doubleo/adminservice/infra/config/properties/PropertiesConfig.java b/src/main/java/com/doubleo/adminservice/infra/config/properties/PropertiesConfig.java new file mode 100644 index 0000000..5e9b93b --- /dev/null +++ b/src/main/java/com/doubleo/adminservice/infra/config/properties/PropertiesConfig.java @@ -0,0 +1,10 @@ +package com.doubleo.adminservice.infra.config.properties; + +import com.doubleo.adminservice.infra.config.jwt.JwtProperties; +import com.doubleo.adminservice.infra.config.redis.RedisProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@EnableConfigurationProperties({JwtProperties.class, RedisProperties.class}) +@Configuration +public class PropertiesConfig {} diff --git a/src/main/java/com/doubleo/adminservice/infra/config/redis/RedisConfig.java b/src/main/java/com/doubleo/adminservice/infra/config/redis/RedisConfig.java new file mode 100644 index 0000000..7864866 --- /dev/null +++ b/src/main/java/com/doubleo/adminservice/infra/config/redis/RedisConfig.java @@ -0,0 +1,35 @@ +package com.doubleo.adminservice.infra.config.redis; + +import java.time.Duration; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.RedisStandaloneConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceClientConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; + +@Configuration +@RequiredArgsConstructor +public class RedisConfig { + + private final RedisProperties redisProperties; + + @Bean + public RedisConnectionFactory redisConnectionFactory() { + RedisStandaloneConfiguration redisStandaloneConfig = + new RedisStandaloneConfiguration(redisProperties.host(), redisProperties.port()); + + if (!redisProperties.password().isBlank()) { + redisStandaloneConfig.setPassword(redisProperties.password()); + } + + LettuceClientConfiguration lettuceClientConfig = + LettuceClientConfiguration.builder() + .commandTimeout(Duration.ofSeconds(1)) + .shutdownTimeout(Duration.ZERO) + .build(); + + return new LettuceConnectionFactory(redisStandaloneConfig, lettuceClientConfig); + } +} diff --git a/src/main/java/com/doubleo/adminservice/infra/config/redis/RedisProperties.java b/src/main/java/com/doubleo/adminservice/infra/config/redis/RedisProperties.java new file mode 100644 index 0000000..4921cbd --- /dev/null +++ b/src/main/java/com/doubleo/adminservice/infra/config/redis/RedisProperties.java @@ -0,0 +1,6 @@ +package com.doubleo.adminservice.infra.config.redis; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "spring.data.redis") +public record RedisProperties(String host, int port, String password) {} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 3b730e8..cedbb3e 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -17,5 +17,6 @@ spring: - swagger - openfeign - datasource + - redis - eureka - actuator diff --git a/src/test/java/com/doubleo/adminservice/AdminServiceApplicationTests.java b/src/test/java/com/doubleo/adminservice/AdminServiceApplicationTests.java index 838ab60..43b1f61 100644 --- a/src/test/java/com/doubleo/adminservice/AdminServiceApplicationTests.java +++ b/src/test/java/com/doubleo/adminservice/AdminServiceApplicationTests.java @@ -2,8 +2,10 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; @SpringBootTest +@ActiveProfiles({"test", "redis", "security"}) class AdminServiceApplicationTests { @Test diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index 21d0d11..03eac81 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -1,6 +1,13 @@ spring: config: activate: - on-profile: "test" + on-profile: test + datasource: - url: jdbc:h2:mem:test;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=false;MODE=MYSQL + url: jdbc:h2:mem:test;DB_CLOSE_DELAY=-1;MODE=MYSQL + + data: + redis: + host: localhost + port: 6379 + password: \ No newline at end of file