diff --git a/build.gradle b/build.gradle index f9693d7..83af5ac 100644 --- a/build.gradle +++ b/build.gradle @@ -21,12 +21,19 @@ repositories { dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jdbc' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'io.jsonwebtoken:jjwt-api:0.12.6' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6' compileOnly 'org.projectlombok:lombok' runtimeOnly 'org.postgresql:postgresql' annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-data-jdbc-test' testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa-test' testCompileOnly 'org.projectlombok:lombok' + testRuntimeOnly 'com.h2database:h2' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' testAnnotationProcessor 'org.projectlombok:lombok' } diff --git a/src/main/java/com/example/auth/controller/AuthController.java b/src/main/java/com/example/auth/controller/AuthController.java new file mode 100644 index 0000000..3465d63 --- /dev/null +++ b/src/main/java/com/example/auth/controller/AuthController.java @@ -0,0 +1,73 @@ +package com.example.auth.controller; + +import com.example.auth.dto.request.SignInRequest; +import com.example.auth.dto.request.RefreshRequest; +import com.example.auth.dto.request.SignOutRequest; +import com.example.auth.dto.response.OauthGoogleCallbackResponse; +import com.example.auth.dto.response.RefreshResponse; +import com.example.auth.dto.response.SignInResponse; +import com.example.auth.global.dto.ApiResponse; +import com.example.auth.global.util.ResponseUtil; +import com.example.auth.service.AuthService; +import java.net.URI; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/auth") +public class AuthController { + + private final AuthService authService; + + public AuthController(AuthService authService) { + this.authService = authService; + } + + @PostMapping("/signin") + public ResponseEntity> signin(@RequestBody SignInRequest request) { + // 로그인 성공 시 Access Token과 Refresh Token을 클라이언트에게 내려줍니다. + SignInResponse response = authService.login(request); + return ResponseEntity.ok(ResponseUtil.success("로그인에 성공했습니다.", response)); + } + + @GetMapping("/oauth/google") + public ResponseEntity googleOauth(@RequestParam(required = false) String state) { + // Google 로그인 페이지로 302 Redirect합니다. + URI redirectUri = authService.createGoogleAuthorizationUri(state); + return ResponseEntity.status(HttpStatus.FOUND) + .location(redirectUri) + .build(); + } + + @GetMapping("/oauth/google/callback") + public ResponseEntity> googleOauthCallback( + @RequestParam String code, + @RequestParam(required = false) String state + ) { + // Google이 내려준 code로 사용자 정보를 확인한 뒤 JWT를 발급합니다. + OauthGoogleCallbackResponse response = + authService.loginWithGoogle(code, state); + return ResponseEntity.ok(ResponseUtil.success("Google 로그인에 성공했습니다.", response)); + } + + @PostMapping("/refresh") + public ResponseEntity> refresh(@RequestBody RefreshRequest request) { + // Refresh Token이 유효하면 새로운 토큰 묶음을 발급합니다. + RefreshResponse response = authService.refresh(request); + return ResponseEntity.ok(ResponseUtil.success("토큰 재발급에 성공했습니다.", response)); + } + + @DeleteMapping("/signout") + public ResponseEntity> signout(@RequestBody SignOutRequest request) { + // 로그아웃은 클라이언트가 가진 Refresh Token을 저장소에서 제거하는 방식입니다. + authService.logout(request); + return ResponseEntity.ok(ResponseUtil.success("로그아웃에 성공했습니다.")); + } +} diff --git a/src/main/java/com/example/auth/dto/request/RefreshRequest.java b/src/main/java/com/example/auth/dto/request/RefreshRequest.java new file mode 100644 index 0000000..487ffaf --- /dev/null +++ b/src/main/java/com/example/auth/dto/request/RefreshRequest.java @@ -0,0 +1,6 @@ +package com.example.auth.dto.request; + +public record RefreshRequest( + String refreshToken +) { +} diff --git a/src/main/java/com/example/auth/dto/request/SignInRequest.java b/src/main/java/com/example/auth/dto/request/SignInRequest.java new file mode 100644 index 0000000..68b0e8c --- /dev/null +++ b/src/main/java/com/example/auth/dto/request/SignInRequest.java @@ -0,0 +1,7 @@ +package com.example.auth.dto.request; + +public record SignInRequest( + String email, + String password +) { +} diff --git a/src/main/java/com/example/auth/dto/request/SignOutRequest.java b/src/main/java/com/example/auth/dto/request/SignOutRequest.java new file mode 100644 index 0000000..dc089b4 --- /dev/null +++ b/src/main/java/com/example/auth/dto/request/SignOutRequest.java @@ -0,0 +1,5 @@ +package com.example.auth.dto.request; + +public record SignOutRequest( + String refresh_token +) {} diff --git a/src/main/java/com/example/auth/dto/response/OauthGoogleCallbackResponse.java b/src/main/java/com/example/auth/dto/response/OauthGoogleCallbackResponse.java new file mode 100644 index 0000000..c276fd0 --- /dev/null +++ b/src/main/java/com/example/auth/dto/response/OauthGoogleCallbackResponse.java @@ -0,0 +1,10 @@ +package com.example.auth.dto.response; + +public record OauthGoogleCallbackResponse( + String name, + String role, + String access_token, + String refresh_token, + Number expires_in +) { +} diff --git a/src/main/java/com/example/auth/dto/response/RefreshResponse.java b/src/main/java/com/example/auth/dto/response/RefreshResponse.java new file mode 100644 index 0000000..0f21fda --- /dev/null +++ b/src/main/java/com/example/auth/dto/response/RefreshResponse.java @@ -0,0 +1,8 @@ +package com.example.auth.dto.response; + +public record RefreshResponse( + String access_token, + String refresh_token, + Number expires_in +) { +} diff --git a/src/main/java/com/example/auth/dto/response/SignInResponse.java b/src/main/java/com/example/auth/dto/response/SignInResponse.java new file mode 100644 index 0000000..72df0d8 --- /dev/null +++ b/src/main/java/com/example/auth/dto/response/SignInResponse.java @@ -0,0 +1,10 @@ +package com.example.auth.dto.response; + +public record SignInResponse( + String name, + String role, + String access_token, + String refresh_token, + String expires_in +) { +} diff --git a/src/main/java/com/example/auth/global/client/GoogleOauthClient.java b/src/main/java/com/example/auth/global/client/GoogleOauthClient.java new file mode 100644 index 0000000..0bd40ca --- /dev/null +++ b/src/main/java/com/example/auth/global/client/GoogleOauthClient.java @@ -0,0 +1,85 @@ +package com.example.auth.global.client; + +import com.example.auth.global.client.dto.response.GoogleTokenResponse; +import com.example.auth.global.client.dto.response.GoogleUserInfoResponse; +import java.net.URI; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.util.StringUtils; +import org.springframework.web.client.RestClient; +import org.springframework.web.util.UriComponentsBuilder; + +@Component +public class GoogleOauthClient { + + private final RestClient restClient; + private final String clientId; + private final String clientSecret; + private final String redirectUri; + private final String authorizationUri; + private final String tokenUri; + private final String userInfoUri; + private final String scope; + + public GoogleOauthClient( + @Value("${oauth.google.client-id}") String clientId, + @Value("${oauth.google.client-secret}") String clientSecret, + @Value("${oauth.google.redirect-uri}") String redirectUri, + @Value("${oauth.google.authorization-uri}") String authorizationUri, + @Value("${oauth.google.token-uri}") String tokenUri, + @Value("${oauth.google.user-info-uri}") String userInfoUri, + @Value("${oauth.google.scope}") String scope + ) { + this.restClient = RestClient.create(); + this.clientId = clientId; + this.clientSecret = clientSecret; + this.redirectUri = redirectUri; + this.authorizationUri = authorizationUri; + this.tokenUri = tokenUri; + this.userInfoUri = userInfoUri; + this.scope = scope; + } + + public URI createAuthorizationUri(String state) { + UriComponentsBuilder builder = UriComponentsBuilder.fromUriString(authorizationUri) + .queryParam("client_id", clientId) + .queryParam("redirect_uri", redirectUri) + .queryParam("response_type", "code") + .queryParam("scope", scope); + + if (StringUtils.hasText(state)) { + builder.queryParam("state", state); + } + + return builder.build() + .encode() + .toUri(); + } + + public GoogleTokenResponse requestToken(String code) { + MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("code", code); + body.add("client_id", clientId); + body.add("client_secret", clientSecret); + body.add("redirect_uri", redirectUri); + body.add("grant_type", "authorization_code"); + + return restClient.post() + .uri(tokenUri) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .body(body) + .retrieve() + .body(GoogleTokenResponse.class); + } + + public GoogleUserInfoResponse requestUserInfo(String accessToken) { + return restClient.get() + .uri(userInfoUri) + .headers(headers -> headers.setBearerAuth(accessToken)) + .retrieve() + .body(GoogleUserInfoResponse.class); + } +} diff --git a/src/main/java/com/example/auth/global/client/UserServiceClient.java b/src/main/java/com/example/auth/global/client/UserServiceClient.java new file mode 100644 index 0000000..52b5cfa --- /dev/null +++ b/src/main/java/com/example/auth/global/client/UserServiceClient.java @@ -0,0 +1,47 @@ +package com.example.auth.global.client; + +import com.example.auth.dto.request.SignInRequest; +import com.example.auth.global.client.dto.response.GoogleUserInfoResponse; +import com.example.auth.global.client.dto.response.UserAuthResponse; +import com.example.auth.global.client.dto.request.UserGoogleOauthRequest; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; + +@Component +public class UserServiceClient { + + private final RestClient restClient; + + public UserServiceClient( + @Value("${user-service.base-url:http://localhost:8081}") String userServiceBaseUrl //임시 + ) { + this.restClient = RestClient.builder() + .baseUrl(userServiceBaseUrl) + .build(); + } + + public UserAuthResponse authenticate(SignInRequest request) { + return restClient.post() + .uri("/internal/users/authenticate") + .body(request) + .retrieve() + .body(UserAuthResponse.class); + } + + public UserAuthResponse authenticateGoogle(GoogleUserInfoResponse request) { + UserGoogleOauthRequest userRequest = new UserGoogleOauthRequest( + request.providerId(), + request.email(), + request.emailVerified(), + request.name(), + request.picture() + ); + + return restClient.post() + .uri("/internal/users/oauth/google") + .body(userRequest) + .retrieve() + .body(UserAuthResponse.class); + } +} diff --git a/src/main/java/com/example/auth/global/client/dto/request/UserGoogleOauthRequest.java b/src/main/java/com/example/auth/global/client/dto/request/UserGoogleOauthRequest.java new file mode 100644 index 0000000..b7df6c2 --- /dev/null +++ b/src/main/java/com/example/auth/global/client/dto/request/UserGoogleOauthRequest.java @@ -0,0 +1,10 @@ +package com.example.auth.global.client.dto.request; + +public record UserGoogleOauthRequest( + String providerId, + String email, + Boolean emailVerified, + String name, + String picture +) { +} diff --git a/src/main/java/com/example/auth/global/client/dto/response/GoogleTokenResponse.java b/src/main/java/com/example/auth/global/client/dto/response/GoogleTokenResponse.java new file mode 100644 index 0000000..f5fc5cb --- /dev/null +++ b/src/main/java/com/example/auth/global/client/dto/response/GoogleTokenResponse.java @@ -0,0 +1,21 @@ +package com.example.auth.global.client.dto.response; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record GoogleTokenResponse( + @JsonProperty("access_token") + String accessToken, + + @JsonProperty("token_type") + String tokenType, + + @JsonProperty("expires_in") + Long expiresIn, + + @JsonProperty("scope") + String scope, + + @JsonProperty("id_token") + String idToken +) { +} diff --git a/src/main/java/com/example/auth/global/client/dto/response/GoogleUserInfoResponse.java b/src/main/java/com/example/auth/global/client/dto/response/GoogleUserInfoResponse.java new file mode 100644 index 0000000..f5c07bf --- /dev/null +++ b/src/main/java/com/example/auth/global/client/dto/response/GoogleUserInfoResponse.java @@ -0,0 +1,18 @@ +package com.example.auth.global.client.dto.response; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record GoogleUserInfoResponse( + @JsonProperty("sub") + String providerId, + + String email, + + @JsonProperty("email_verified") + Boolean emailVerified, + + String name, + + String picture +) { +} diff --git a/src/main/java/com/example/auth/global/client/dto/response/UserAuthResponse.java b/src/main/java/com/example/auth/global/client/dto/response/UserAuthResponse.java new file mode 100644 index 0000000..e358f81 --- /dev/null +++ b/src/main/java/com/example/auth/global/client/dto/response/UserAuthResponse.java @@ -0,0 +1,9 @@ +package com.example.auth.global.client.dto.response; + +import java.util.UUID; + +public record UserAuthResponse( + UUID userId, + String name, + String role +) {} diff --git a/src/main/java/com/example/auth/global/config/PasswordEncoderConfig.java b/src/main/java/com/example/auth/global/config/PasswordEncoderConfig.java new file mode 100644 index 0000000..a2c44da --- /dev/null +++ b/src/main/java/com/example/auth/global/config/PasswordEncoderConfig.java @@ -0,0 +1,15 @@ +package com.example.auth.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +@Configuration +public class PasswordEncoderConfig { + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/src/main/java/com/example/auth/global/config/SecurityConfig.java b/src/main/java/com/example/auth/global/config/SecurityConfig.java new file mode 100644 index 0000000..9edd70e --- /dev/null +++ b/src/main/java/com/example/auth/global/config/SecurityConfig.java @@ -0,0 +1,41 @@ +package com.example.auth.global.config; + +import com.example.auth.global.security.JwtAuthenticationFilter; +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.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@Configuration +public class SecurityConfig { + + private final JwtAuthenticationFilter jwtAuthenticationFilter; + + public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) { + this.jwtAuthenticationFilter = jwtAuthenticationFilter; + } + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + return http + // JWT 인증은 세션을 사용하지 않으므로 CSRF와 세션 생성 X + .csrf(AbstractHttpConfigurer::disable) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + // 로그인과 토큰 재발급 API는 인증 없이 접근할 수 있게 열어둠 + .authorizeHttpRequests(auth -> auth + .requestMatchers( + "/auth/signin", + "/auth/refresh", + "/auth/oauth/google", + "/auth/oauth/google/callback" + ).permitAll() + .anyRequest().authenticated() + ) + // UsernamePasswordAuthenticationFilter 전에 JWT 필터를 실행해 요청 인증을 먼저 처리 + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) + .build(); + } +} diff --git a/src/main/java/com/example/auth/global/dto/ApiResponse.java b/src/main/java/com/example/auth/global/dto/ApiResponse.java new file mode 100644 index 0000000..490f6ee --- /dev/null +++ b/src/main/java/com/example/auth/global/dto/ApiResponse.java @@ -0,0 +1,6 @@ +package com.example.auth.global.dto; + +public record ApiResponse ( + String message, + T data +){} diff --git a/src/main/java/com/example/auth/global/security/JwtAuthenticationFilter.java b/src/main/java/com/example/auth/global/security/JwtAuthenticationFilter.java new file mode 100644 index 0000000..b46adac --- /dev/null +++ b/src/main/java/com/example/auth/global/security/JwtAuthenticationFilter.java @@ -0,0 +1,50 @@ +package com.example.auth.global.security; + +import io.jsonwebtoken.JwtException; +import java.io.IOException; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +@Component +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtProvider jwtProvider; + private final TokenExtractor tokenExtractor; + + public JwtAuthenticationFilter(JwtProvider jwtProvider, TokenExtractor tokenExtractor) { + this.jwtProvider = jwtProvider; + this.tokenExtractor = tokenExtractor; + } + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain + ) throws ServletException, IOException { + try { + // 요청에 Access Token이 있으면 검증 후 SecurityContext에 인증 정보를 저장 + tokenExtractor.extractAccessToken(request) + .ifPresent(this::setAuthentication); + + filterChain.doFilter(request, response); + } catch (JwtException | IllegalArgumentException exception) { + // 토큰이 위조됐거나 만료된 경우 인증 실패 응답을 내려줌 + SecurityContextHolder.clearContext(); + response.sendError(HttpStatus.UNAUTHORIZED.value(), "유효하지 않은 JWT 토큰입니다."); + } + } + + private void setAuthentication(String token) { + jwtProvider.validateToken(token); + Authentication authentication = jwtProvider.getAuthentication(token); + SecurityContextHolder.getContext().setAuthentication(authentication); + } +} diff --git a/src/main/java/com/example/auth/global/security/JwtProvider.java b/src/main/java/com/example/auth/global/security/JwtProvider.java new file mode 100644 index 0000000..5043067 --- /dev/null +++ b/src/main/java/com/example/auth/global/security/JwtProvider.java @@ -0,0 +1,116 @@ +package com.example.auth.global.security; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.Duration; +import java.util.Date; +import javax.crypto.SecretKey; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.stereotype.Component; + +@Component +public class JwtProvider { + + private final SecretKey secretKey; + private final long accessTokenExpirationMillis; + private final long refreshTokenExpirationMillis; + + public JwtProvider( + @Value("${jwt.secret:change-this-secret-key-for-local-development-only}") String secret, + @Value("${jwt.access-token-expiration:30m}") Duration accessTokenExpiration, + @Value("${jwt.refresh-token-expiration:14d}") Duration refreshTokenExpiration + ) { + this.secretKey = createSecretKey(secret); + this.accessTokenExpirationMillis = accessTokenExpiration.toMillis(); + this.refreshTokenExpirationMillis = refreshTokenExpiration.toMillis(); + } + + public String createAccessToken(String subject, String role) { + return createToken(subject, role, accessTokenExpirationMillis); + } + + public String createRefreshToken(String subject, String role) { + return createToken(subject, role, refreshTokenExpirationMillis); + } + + public long getAccessTokenExpirationMillis() { + return accessTokenExpirationMillis; + } + + public long getRefreshTokenExpirationMillis() { + return refreshTokenExpirationMillis; + } + + // JWT 안의 subject와 role을 Spring Security가 이해하는 Authentication 객체로 바꿉니다. + public Authentication getAuthentication(String token) { + Claims claims = parseClaims(token); + String subject = claims.getSubject(); + String role = claims.get("role", String.class); + + return new UsernamePasswordAuthenticationToken( + subject, + null, + java.util.List.of(new SimpleGrantedAuthority(role)) + ); + } + + // 토큰 서명과 만료 시간을 검증 + public boolean validateToken(String token) { + parseClaims(token); + return true; + } + + // 로그아웃이나 재발급에서 토큰 주인을 찾을 때 사용 + public String getSubject(String token) { + return parseClaims(token).getSubject(); + } + + public String getRole(String token) { + return parseClaims(token).get("role", String.class); + } + + private String createToken(String subject, String role, long expirationMillis) { + Date now = new Date(); + Date expiration = new Date(now.getTime() + expirationMillis); + + return Jwts.builder() + .subject(subject) + .claim("role", role) + .issuedAt(now) + .expiration(expiration) + .signWith(secretKey) + .compact(); + } + + private Claims parseClaims(String token) { + return Jwts.parser() + .verifyWith(secretKey) + .build() + .parseSignedClaims(token) + .getPayload(); + } + + private SecretKey createSecretKey(String secret) { + byte[] keyBytes = secret.getBytes(StandardCharsets.UTF_8); + + if (keyBytes.length < 32) { + keyBytes = sha256(keyBytes); + } + + return Keys.hmacShaKeyFor(keyBytes); + } + + private byte[] sha256(byte[] value) { + try { + return MessageDigest.getInstance("SHA-256").digest(value); + } catch (Exception exception) { + throw new IllegalStateException("JWT secret key를 생성할 수 없습니다.", exception); + } + } +} diff --git a/src/main/java/com/example/auth/global/security/TokenExtractor.java b/src/main/java/com/example/auth/global/security/TokenExtractor.java new file mode 100644 index 0000000..8c447d9 --- /dev/null +++ b/src/main/java/com/example/auth/global/security/TokenExtractor.java @@ -0,0 +1,24 @@ +package com.example.auth.global.security; + +import jakarta.servlet.http.HttpServletRequest; +import java.util.Optional; +import org.springframework.http.HttpHeaders; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +@Component +public class TokenExtractor { + + private static final String BEARER_PREFIX = "Bearer "; + + // 헤더에서 토큰 값만 분리 + public Optional extractAccessToken(HttpServletRequest request) { + String authorizationHeader = request.getHeader(HttpHeaders.AUTHORIZATION); + + if (!StringUtils.hasText(authorizationHeader) || !authorizationHeader.startsWith(BEARER_PREFIX)) { + return Optional.empty(); + } + + return Optional.of(authorizationHeader.substring(BEARER_PREFIX.length())); + } +} diff --git a/src/main/java/com/example/auth/global/util/ResponseUtil.java b/src/main/java/com/example/auth/global/util/ResponseUtil.java new file mode 100644 index 0000000..c1750c1 --- /dev/null +++ b/src/main/java/com/example/auth/global/util/ResponseUtil.java @@ -0,0 +1,12 @@ +package com.example.auth.global.util; + +import com.example.auth.global.dto.ApiResponse; + +public class ResponseUtil { + public static ApiResponse success(String message,T data){ + return new ApiResponse<>(message,data); + } + public static ApiResponse success(String message){ + return new ApiResponse<>(message,null); + } +} diff --git a/src/main/java/com/example/auth/infra/RefreshTokenRepository.java b/src/main/java/com/example/auth/infra/RefreshTokenRepository.java new file mode 100644 index 0000000..107fc73 --- /dev/null +++ b/src/main/java/com/example/auth/infra/RefreshTokenRepository.java @@ -0,0 +1,37 @@ +package com.example.auth.infra; + +import java.time.Duration; +import java.util.Objects; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Repository; + +@Repository +public class RefreshTokenRepository { + + private static final String REFRESH_TOKEN_KEY_PREFIX = "auth:refresh-token:"; + + private final StringRedisTemplate redisTemplate; + + public RefreshTokenRepository(StringRedisTemplate redisTemplate) { + this.redisTemplate = redisTemplate; + } + + // Refresh Token은 서버에도 저장해 재발급과 로그아웃 시 유효성을 확인 + public void save(String subject, String refreshToken, long ttlMillis) { + redisTemplate.opsForValue() + .set(createKey(subject), refreshToken, Duration.ofMillis(ttlMillis)); + } + + public boolean existsBySubjectAndToken(String subject, String refreshToken) { + String storedRefreshToken = redisTemplate.opsForValue().get(createKey(subject)); + return Objects.equals(storedRefreshToken, refreshToken); + } + + public void deleteBySubject(String subject) { + redisTemplate.delete(createKey(subject)); + } + + private String createKey(String subject) { + return REFRESH_TOKEN_KEY_PREFIX + subject; + } +} diff --git a/src/main/java/com/example/auth/service/AuthService.java b/src/main/java/com/example/auth/service/AuthService.java new file mode 100644 index 0000000..55b8f01 --- /dev/null +++ b/src/main/java/com/example/auth/service/AuthService.java @@ -0,0 +1,142 @@ +package com.example.auth.service; + +import com.example.auth.dto.request.RefreshRequest; +import com.example.auth.dto.request.SignInRequest; +import com.example.auth.dto.request.SignOutRequest; +import com.example.auth.dto.response.OauthGoogleCallbackResponse; +import com.example.auth.dto.response.RefreshResponse; +import com.example.auth.dto.response.SignInResponse; +import com.example.auth.global.client.GoogleOauthClient; +import com.example.auth.global.client.UserServiceClient; +import com.example.auth.global.client.dto.response.GoogleTokenResponse; +import com.example.auth.global.client.dto.response.GoogleUserInfoResponse; +import com.example.auth.global.client.dto.response.UserAuthResponse; +import java.net.URI; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +@Service +public class AuthService { + + private final GoogleOauthClient googleOauthClient; + private final UserServiceClient userServiceClient; + private final AuthValidator authValidator; + private final RoleProcessor roleProcessor; + private final TokenService tokenService; + + public AuthService( + GoogleOauthClient googleOauthClient, + UserServiceClient userServiceClient, + AuthValidator authValidator, + RoleProcessor roleProcessor, + TokenService tokenService + ) { + this.googleOauthClient = googleOauthClient; + this.userServiceClient = userServiceClient; + this.authValidator = authValidator; + this.roleProcessor = roleProcessor; + this.tokenService = tokenService; + } + + // 로그인 + public SignInResponse login(SignInRequest request) { + + authValidator.validateLoginRequest(request); + + UserAuthResponse user = + userServiceClient.authenticate(request); + + authValidator.validateAuthenticatedUser(user); + + String role = + roleProcessor.normalizeRole(user.role()); + + TokenService.TokenPair tokenPair = + tokenService.issueTokens( + user.userId().toString(), + role + ); + + return new SignInResponse( + user.name(), + role, + tokenPair.accessToken(), + tokenPair.refreshToken(), + String.valueOf(tokenService.accessTokenExpiresInSeconds()) + ); + } + + // Google OAuth 로그인 페이지로 이동할 URL 생성 + public URI createGoogleAuthorizationUri(String state) { + return googleOauthClient.createAuthorizationUri(state); + } + + // Google OAuth callback code로 사용자 인증 후 JWT 발급 + public OauthGoogleCallbackResponse loginWithGoogle(String code, String state) { + + authValidator.validateGoogleAuthorizationCode(code); + + GoogleTokenResponse googleToken = + googleOauthClient.requestToken(code); + + if (googleToken == null || !StringUtils.hasText(googleToken.accessToken())) { + throw new IllegalArgumentException("Google Access Token을 발급받을 수 없습니다."); + } + + GoogleUserInfoResponse googleUser = + googleOauthClient.requestUserInfo(googleToken.accessToken()); + + UserAuthResponse user = + userServiceClient.authenticateGoogle(googleUser); + + authValidator.validateAuthenticatedUser(user); + + String role = + roleProcessor.normalizeRole(user.role()); + + TokenService.TokenPair tokenPair = + tokenService.issueTokens( + user.userId().toString(), + role + ); + + return new OauthGoogleCallbackResponse( + user.name(), + role, + tokenPair.accessToken(), + tokenPair.refreshToken(), + tokenService.accessTokenExpiresInSeconds() + ); + } + + // 토큰 재발급 + public RefreshResponse refresh(RefreshRequest request) { + + authValidator.validateRefreshRequest(request); + + String subject = + tokenService.getValidRefreshTokenSubject( + request.refreshToken() + ); + + String role = + tokenService.getRole(request.refreshToken()); + + TokenService.TokenPair tokenPair = + tokenService.issueTokens(subject, role); + + return new RefreshResponse( + tokenPair.accessToken(), + tokenPair.refreshToken(), + tokenService.accessTokenExpiresInSeconds() + ); + } + + // 로그아웃 + public void logout(SignOutRequest request) { + + authValidator.validateSignOutRequest(request); + + tokenService.deleteRefreshToken(request.refresh_token()); + } +} diff --git a/src/main/java/com/example/auth/service/AuthValidator.java b/src/main/java/com/example/auth/service/AuthValidator.java new file mode 100644 index 0000000..122ac9e --- /dev/null +++ b/src/main/java/com/example/auth/service/AuthValidator.java @@ -0,0 +1,41 @@ +package com.example.auth.service; + +import com.example.auth.dto.request.RefreshRequest; +import com.example.auth.dto.request.SignInRequest; +import com.example.auth.dto.request.SignOutRequest; +import com.example.auth.global.client.dto.response.UserAuthResponse; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +@Component +public class AuthValidator { + public void validateLoginRequest(SignInRequest request) { + if (request == null || !StringUtils.hasText(request.email()) || !StringUtils.hasText(request.password())) { + throw new IllegalArgumentException("아이디와 비밀번호를 입력해주세요."); + } + } + + public void validateRefreshRequest(RefreshRequest request) { + if (request == null || !StringUtils.hasText(request.refreshToken())) { + throw new IllegalArgumentException("Refresh Token을 입력해주세요."); + } + } + + public void validateGoogleAuthorizationCode(String code) { + if (!StringUtils.hasText(code)) { + throw new IllegalArgumentException("Google Authorization Code를 입력해주세요."); + } + } + + public void validateSignOutRequest(SignOutRequest request) { + if (request == null || !StringUtils.hasText(request.refresh_token())) { + throw new IllegalArgumentException("Refresh Token을 입력해주세요."); + } + } + + public void validateAuthenticatedUser(UserAuthResponse user) { + if (user == null || user.userId() == null) { + throw new IllegalArgumentException("사용자 인증 정보를 확인할 수 없습니다."); + } + } +} diff --git a/src/main/java/com/example/auth/service/RoleProcessor.java b/src/main/java/com/example/auth/service/RoleProcessor.java new file mode 100644 index 0000000..26b6b30 --- /dev/null +++ b/src/main/java/com/example/auth/service/RoleProcessor.java @@ -0,0 +1,19 @@ +package com.example.auth.service; + +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +@Component +public class RoleProcessor { + + public String normalizeRole(String role) { + if (!StringUtils.hasText(role)) { + return "ROLE_USER"; + } + + return role.startsWith("ROLE_") + ? role + : "ROLE_" + role; + } + +} diff --git a/src/main/java/com/example/auth/service/TokenService.java b/src/main/java/com/example/auth/service/TokenService.java new file mode 100644 index 0000000..a5ef24e --- /dev/null +++ b/src/main/java/com/example/auth/service/TokenService.java @@ -0,0 +1,77 @@ +package com.example.auth.service; + +import com.example.auth.global.security.JwtProvider; +import com.example.auth.infra.RefreshTokenRepository; +import org.springframework.stereotype.Service; + +@Service +public class TokenService { + + private final JwtProvider jwtProvider; + private final RefreshTokenRepository refreshTokenRepository; + + public TokenService( + JwtProvider jwtProvider, + RefreshTokenRepository refreshTokenRepository + ) { + this.jwtProvider = jwtProvider; + this.refreshTokenRepository = refreshTokenRepository; + } + + public TokenPair issueTokens( + String subject, + String role + ) { + + String accessToken = + jwtProvider.createAccessToken(subject, role); + + String refreshToken = + jwtProvider.createRefreshToken(subject, role); + + refreshTokenRepository.save( + subject, + refreshToken, + jwtProvider.getRefreshTokenExpirationMillis() + ); + + return new TokenPair( + accessToken, + refreshToken + ); + } + + public long accessTokenExpiresInSeconds() { + return jwtProvider.getAccessTokenExpirationMillis() / 1000; + } + + public String getValidRefreshTokenSubject(String refreshToken) { + jwtProvider.validateToken(refreshToken); + + String subject = jwtProvider.getSubject(refreshToken); + + if (!refreshTokenRepository.existsBySubjectAndToken(subject, refreshToken)) { + throw new IllegalArgumentException( + "저장된 Refresh Token과 일치하지 않습니다." + ); + } + + return subject; + } + + public String getRole(String token) { + return jwtProvider.getRole(token); + } + + public void deleteRefreshToken(String refreshToken) { + String subject = + getValidRefreshTokenSubject(refreshToken); + + refreshTokenRepository.deleteBySubject(subject); + } + + public record TokenPair( + String accessToken, + String refreshToken + ) {} +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties deleted file mode 100644 index c90eb26..0000000 --- a/src/main/resources/application.properties +++ /dev/null @@ -1 +0,0 @@ -spring.application.name=Auth diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..b177965 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,46 @@ +spring: + application: + name: Auth + + # PostgreSQL 연결 정보입니다. Docker Compose에서는 postgres 서비스 이름을 DB_HOST로 사용합니다. + datasource: + url: "jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:auth_db}" + username: "${DB_USERNAME:auth_user}" + password: "${DB_PASSWORD:auth_password}" + driver-class-name: org.postgresql.Driver + + # 개발 초기에는 update가 편하고, 운영에서는 validate 또는 none으로 바꾸는 것을 권장합니다. + jpa: + hibernate: + ddl-auto: "${JPA_DDL_AUTO:update}" + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect + + # Refresh Token 저장과 로그아웃 처리를 위한 Redis 연결 정보입니다. + data: + redis: + host: "${REDIS_HOST:localhost}" + port: "${REDIS_PORT:6379}" + password: "${REDIS_PASSWORD:}" + +# JWT 서명에 사용할 비밀키입니다. 운영 환경에서는 환경 변수로 충분히 긴 값을 주입해주세요. +jwt: + secret: "${JWT_SECRET:change-this-secret-key-for-local-development-only}" + access-token-expiration: 30m + refresh-token-expiration: 14d + +# Auth 서버가 로그인 검증을 위임할 User 서비스 주소입니다. +user-service: + base-url: "${USER_SERVICE_BASE_URL:http://localhost:8081}" + +# Google OAuth 로그인 설정입니다. +oauth: + google: + client-id: "${GOOGLE_CLIENT_ID:}" + client-secret: "${GOOGLE_CLIENT_SECRET:}" + redirect-uri: "${GOOGLE_REDIRECT_URI:http://localhost:8080/auth/oauth/google/callback}" + authorization-uri: "https://accounts.google.com/o/oauth2/v2/auth" + token-uri: "https://oauth2.googleapis.com/token" + user-info-uri: "https://www.googleapis.com/oauth2/v3/userinfo" + scope: "openid email profile" diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml new file mode 100644 index 0000000..c89bf67 --- /dev/null +++ b/src/test/resources/application.yml @@ -0,0 +1,23 @@ +spring: + datasource: + url: "jdbc:h2:mem:auth-test;MODE=PostgreSQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE" + driver-class-name: org.h2.Driver + username: sa + password: "" + + jpa: + hibernate: + ddl-auto: create-drop + + autoconfigure: + exclude: org.springframework.boot.data.redis.autoconfigure.RedisAutoConfiguration + +oauth: + google: + client-id: test-google-client-id + client-secret: test-google-client-secret + redirect-uri: "http://localhost:8080/auth/oauth/google/callback" + authorization-uri: "https://accounts.google.com/o/oauth2/v2/auth" + token-uri: "https://oauth2.googleapis.com/token" + user-info-uri: "https://www.googleapis.com/oauth2/v3/userinfo" + scope: "openid email profile"