diff --git a/src/main/java/gg/agit/konect/domain/user/controller/UserApi.java b/src/main/java/gg/agit/konect/domain/user/controller/UserApi.java index 978d67f1..0c538f87 100644 --- a/src/main/java/gg/agit/konect/domain/user/controller/UserApi.java +++ b/src/main/java/gg/agit/konect/domain/user/controller/UserApi.java @@ -9,6 +9,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import gg.agit.konect.domain.user.dto.SignupRequest; +import gg.agit.konect.domain.user.dto.UserAccessTokenResponse; import gg.agit.konect.domain.user.dto.UserInfoResponse; import gg.agit.konect.domain.user.dto.UserUpdateRequest; import gg.agit.konect.global.auth.annotation.PublicApi; @@ -16,7 +17,7 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpSession; +import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; @Tag(name = "(Normal) User: 유저", description = "유저 API") @@ -38,9 +39,9 @@ public interface UserApi { @PostMapping("/signup") @PublicApi ResponseEntity signup( - HttpServletRequest httpServletRequest, - HttpSession session, - @RequestBody @Valid SignupRequest request + HttpServletRequest request, + HttpServletResponse response, + @RequestBody @Valid SignupRequest signupRequest ); @Operation(summary = "로그인한 사용자의 정보를 조회한다.") @@ -50,16 +51,21 @@ ResponseEntity signup( @Operation(summary = "로그인한 사용자의 정보를 수정한다.") @PutMapping("/me") ResponseEntity updateMyInfo( - HttpSession session, + @UserId Integer userId, @RequestBody @Valid UserUpdateRequest request ); @Operation(summary = "로그아웃한다.") @PostMapping("/logout") @PublicApi - ResponseEntity logout(HttpServletRequest request); + ResponseEntity logout(HttpServletRequest request, HttpServletResponse response); + + @Operation(summary = "리프레시 토큰으로 액세스 토큰을 재발급한다.") + @PostMapping("/refresh") + @PublicApi + ResponseEntity refresh(HttpServletRequest request, HttpServletResponse response); @Operation(summary = "회원탈퇴를 한다.") @DeleteMapping("/withdraw") - ResponseEntity withdraw(HttpServletRequest request, @UserId Integer userId); + ResponseEntity withdraw(HttpServletRequest request, HttpServletResponse response, @UserId Integer userId); } diff --git a/src/main/java/gg/agit/konect/domain/user/controller/UserController.java b/src/main/java/gg/agit/konect/domain/user/controller/UserController.java index f0f341d1..367d4de0 100644 --- a/src/main/java/gg/agit/konect/domain/user/controller/UserController.java +++ b/src/main/java/gg/agit/konect/domain/user/controller/UserController.java @@ -4,17 +4,23 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.util.WebUtils; import gg.agit.konect.domain.user.dto.SignupRequest; +import gg.agit.konect.domain.user.dto.UserAccessTokenResponse; import gg.agit.konect.domain.user.dto.UserInfoResponse; import gg.agit.konect.domain.user.dto.UserUpdateRequest; -import gg.agit.konect.domain.user.enums.Provider; import gg.agit.konect.domain.user.service.UserService; +import gg.agit.konect.global.auth.annotation.PublicApi; import gg.agit.konect.global.auth.annotation.UserId; -import gg.agit.konect.global.code.ApiResponseCode; -import gg.agit.konect.global.exception.CustomException; +import gg.agit.konect.global.auth.AccessTokenBlacklistService; +import gg.agit.konect.global.auth.JwtProvider; +import gg.agit.konect.global.auth.token.AuthCookieService; +import gg.agit.konect.domain.user.service.RefreshTokenService; +import gg.agit.konect.domain.user.service.SignupTokenService; +import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpSession; +import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -23,28 +29,35 @@ @RequestMapping("/users") public class UserController implements UserApi { + private static final String AUTHORIZATION_HEADER = "Authorization"; + private static final String BEARER_PREFIX = "Bearer "; + private final UserService userService; + private final SignupTokenService signupTokenService; + private final JwtProvider jwtProvider; + private final RefreshTokenService refreshTokenService; + private final AuthCookieService authCookieService; + private final AccessTokenBlacklistService accessTokenBlacklistService; @Override + @PublicApi public ResponseEntity signup( - HttpServletRequest httpServletRequest, - HttpSession session, - @RequestBody @Valid SignupRequest request + HttpServletRequest request, + HttpServletResponse response, + @RequestBody @Valid SignupRequest signupRequest ) { - String email = (String)session.getAttribute("email"); - Provider provider = (Provider)session.getAttribute("provider"); - String providerId = (String)session.getAttribute("providerId"); + String signupToken = getCookieValue(request, AuthCookieService.SIGNUP_TOKEN_COOKIE); + SignupTokenService.SignupClaims claims = signupTokenService.consumeOrThrow(signupToken); - if (email == null || provider == null) { - throw CustomException.of(ApiResponseCode.INVALID_SESSION); - } + Integer userId = userService.signup(claims.email(), claims.providerId(), claims.provider(), signupRequest); - Integer userId = userService.signup(email, providerId, provider, request); + authCookieService.clearSignupToken(request, response); - session.invalidate(); + String refreshToken = refreshTokenService.issue(userId); + authCookieService.setRefreshToken(request, response, refreshToken, refreshTokenService.refreshTtl()); - HttpSession newSession = httpServletRequest.getSession(true); - newSession.setAttribute("userId", userId); + String accessToken = jwtProvider.createToken(userId); + response.setHeader("Authorization", "Bearer " + accessToken); return ResponseEntity.ok().build(); } @@ -58,32 +71,66 @@ public ResponseEntity getMyInfo(@UserId Integer userId) { @Override public ResponseEntity updateMyInfo( - HttpSession session, + @UserId Integer userId, @RequestBody @Valid UserUpdateRequest request ) { - Integer userId = (Integer)session.getAttribute("userId"); - userService.updateUserInfo(userId, request); return ResponseEntity.ok().build(); } @Override - public ResponseEntity logout(HttpServletRequest request) { - HttpSession session = request.getSession(false); + @PublicApi + public ResponseEntity logout(HttpServletRequest request, HttpServletResponse response) { + String accessToken = resolveBearerToken(request); + accessTokenBlacklistService.blacklist(accessToken); - if (session != null) { - session.invalidate(); - } + String refreshToken = getCookieValue(request, AuthCookieService.REFRESH_TOKEN_COOKIE); + refreshTokenService.revoke(refreshToken); + + authCookieService.clearRefreshToken(request, response); + authCookieService.clearSignupToken(request, response); return ResponseEntity.ok().build(); } @Override - public ResponseEntity withdraw(HttpServletRequest request, @UserId Integer userId) { + @PublicApi + public ResponseEntity refresh(HttpServletRequest request, HttpServletResponse response) { + String oldAccessToken = resolveBearerToken(request); + accessTokenBlacklistService.blacklist(oldAccessToken); + + String refreshToken = getCookieValue(request, AuthCookieService.REFRESH_TOKEN_COOKIE); + RefreshTokenService.Rotated rotated = refreshTokenService.rotate(refreshToken); + + String accessToken = jwtProvider.createToken(rotated.userId()); + authCookieService.setRefreshToken(request, response, rotated.refreshToken(), refreshTokenService.refreshTtl()); + + return ResponseEntity.ok(new UserAccessTokenResponse(accessToken)); + } + + private String resolveBearerToken(HttpServletRequest request) { + String authorization = request.getHeader(AUTHORIZATION_HEADER); + if (authorization == null || !authorization.startsWith(BEARER_PREFIX)) { + return null; + } + return authorization.substring(BEARER_PREFIX.length()); + } + + @Override + public ResponseEntity withdraw( + HttpServletRequest request, + HttpServletResponse response, + @UserId Integer userId + ) { userService.deleteUser(userId); - logout(request); + logout(request, response); return ResponseEntity.noContent().build(); } + + private String getCookieValue(HttpServletRequest request, String name) { + Cookie cookie = WebUtils.getCookie(request, name); + return cookie == null ? null : cookie.getValue(); + } } diff --git a/src/main/java/gg/agit/konect/domain/user/dto/UserAccessTokenResponse.java b/src/main/java/gg/agit/konect/domain/user/dto/UserAccessTokenResponse.java new file mode 100644 index 00000000..0d3ac0b2 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/user/dto/UserAccessTokenResponse.java @@ -0,0 +1,15 @@ +package gg.agit.konect.domain.user.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record UserAccessTokenResponse( + @Schema( + description = "액세스 토큰", + example = "eyJhbGciOiJIUzI1NiJ9...", + requiredMode = REQUIRED + ) + String accessToken +) { +} diff --git a/src/main/java/gg/agit/konect/domain/user/service/RefreshTokenService.java b/src/main/java/gg/agit/konect/domain/user/service/RefreshTokenService.java new file mode 100644 index 00000000..f8ec8fcb --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/user/service/RefreshTokenService.java @@ -0,0 +1,166 @@ +package gg.agit.konect.domain.user.service; + +import java.security.SecureRandom; +import java.time.Duration; +import java.util.Base64; +import java.util.List; + +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.global.exception.CustomException; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class RefreshTokenService { + + private static final int TOKEN_BYTES = 32; + + private static final Duration REFRESH_TOKEN_TTL = Duration.ofDays(30); + + private static final String ACTIVE_PREFIX = "auth:refresh:active:"; + private static final String REVOKED_PREFIX = "auth:refresh:revoked:"; + private static final String USER_SET_PREFIX = "auth:refresh:user:"; + + private static final DefaultRedisScript GET_DEL_SCRIPT = + new DefaultRedisScript<>( + "local v = redis.call('GET', KEYS[1]); " + + "if v then redis.call('DEL', KEYS[1]); end; " + + "return v;", + String.class + ); + + private final SecureRandom secureRandom = new SecureRandom(); + + private final StringRedisTemplate redis; + + public Duration refreshTtl() { + return REFRESH_TOKEN_TTL; + } + + public String issue(Integer userId) { + if (userId == null) { + throw new IllegalArgumentException("userId is required"); + } + + String token = generateToken(); + Duration ttl = refreshTtl(); + + redis.opsForValue().set(activeKey(token), userId.toString(), ttl); + redis.opsForSet().add(userSetKey(userId), token); + redis.expire(userSetKey(userId), ttl); + + return token; + } + + public Rotated rotate(String refreshToken) { + if (!StringUtils.hasText(refreshToken)) { + throw CustomException.of(ApiResponseCode.INVALID_SESSION); + } + + Integer userId = consumeActive(refreshToken); + if (userId == null) { + Integer revokedUserId = findRevokedUserId(refreshToken); + if (revokedUserId != null) { + revokeAll(revokedUserId); + } + throw CustomException.of(ApiResponseCode.INVALID_SESSION); + } + + String newToken = issue(userId); + return new Rotated(userId, newToken); + } + + public void revoke(String refreshToken) { + if (!StringUtils.hasText(refreshToken)) { + return; + } + + String value = redis.execute(GET_DEL_SCRIPT, List.of(activeKey(refreshToken))); + Integer userId = parseUserId(value); + if (userId == null) { + return; + } + + Duration ttl = refreshTtl(); + redis.opsForValue().set(revokedKey(refreshToken), userId.toString(), ttl); + redis.opsForSet().remove(userSetKey(userId), refreshToken); + } + + public void revokeAll(Integer userId) { + if (userId == null) { + return; + } + + String setKey = userSetKey(userId); + var tokens = redis.opsForSet().members(setKey); + + if (tokens == null || tokens.isEmpty()) { + redis.delete(setKey); + return; + } + + Duration ttl = refreshTtl(); + for (String token : tokens) { + redis.delete(activeKey(token)); + redis.opsForValue().set(revokedKey(token), userId.toString(), ttl); + } + + redis.delete(setKey); + } + + private Integer consumeActive(String token) { + String value = redis.execute(GET_DEL_SCRIPT, List.of(activeKey(token))); + Integer userId = parseUserId(value); + if (userId == null) { + return null; + } + + Duration ttl = refreshTtl(); + redis.opsForValue().set(revokedKey(token), userId.toString(), ttl); + redis.opsForSet().remove(userSetKey(userId), token); + return userId; + } + + private Integer findRevokedUserId(String token) { + String value = redis.opsForValue().get(revokedKey(token)); + return parseUserId(value); + } + + private String activeKey(String token) { + return ACTIVE_PREFIX + token; + } + + private String revokedKey(String token) { + return REVOKED_PREFIX + token; + } + + private String userSetKey(Integer userId) { + return USER_SET_PREFIX + userId; + } + + private String generateToken() { + byte[] bytes = new byte[TOKEN_BYTES]; + secureRandom.nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + + private Integer parseUserId(String value) { + if (!StringUtils.hasText(value)) { + return null; + } + + try { + return Integer.parseInt(value); + } catch (NumberFormatException e) { + return null; + } + } + + public record Rotated(Integer userId, String refreshToken) { + } +} diff --git a/src/main/java/gg/agit/konect/domain/user/service/SignupTokenService.java b/src/main/java/gg/agit/konect/domain/user/service/SignupTokenService.java new file mode 100644 index 00000000..fb2285b3 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/user/service/SignupTokenService.java @@ -0,0 +1,111 @@ +package gg.agit.konect.domain.user.service; + +import java.security.SecureRandom; +import java.time.Duration; +import java.util.Base64; +import java.util.List; + +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import gg.agit.konect.domain.user.enums.Provider; +import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.global.exception.CustomException; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class SignupTokenService { + + private static final int TOKEN_BYTES = 32; + + private static final Duration SIGNUP_TOKEN_TTL = Duration.ofMinutes(10); + private static final String KEY_PREFIX = "auth:signup:"; + private static final String DELIMITER = "|"; + private static final int EXPECTED_PARTS = 3; + + private static final DefaultRedisScript GET_DEL_SCRIPT = + new DefaultRedisScript<>( + "local v = redis.call('GET', KEYS[1]); " + + "if v then redis.call('DEL', KEYS[1]); end; " + + "return v;", + String.class + ); + + private final SecureRandom secureRandom = new SecureRandom(); + + private final StringRedisTemplate redis; + + public Duration signupTtl() { + return SIGNUP_TOKEN_TTL; + } + + public String issue(String email, Provider provider, String providerId) { + if (!StringUtils.hasText(email) || provider == null) { + throw new IllegalArgumentException("email and provider are required"); + } + + String token = generateToken(); + redis.opsForValue().set(key(token), serialize(new SignupClaims(email, provider, providerId)), signupTtl()); + return token; + } + + public SignupClaims consumeOrThrow(String token) { + if (!StringUtils.hasText(token)) { + throw CustomException.of(ApiResponseCode.INVALID_SESSION); + } + + String value = redis.execute(GET_DEL_SCRIPT, List.of(key(token))); + SignupClaims claims = deserialize(value); + if (claims == null) { + throw CustomException.of(ApiResponseCode.INVALID_SESSION); + } + return claims; + } + + private String key(String token) { + return KEY_PREFIX + token; + } + + private String generateToken() { + byte[] bytes = new byte[TOKEN_BYTES]; + secureRandom.nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + + private String serialize(SignupClaims claims) { + String safeProviderId = claims.providerId() == null ? "" : claims.providerId(); + return claims.email() + DELIMITER + claims.provider().name() + DELIMITER + safeProviderId; + } + + private SignupClaims deserialize(String value) { + if (!StringUtils.hasText(value)) { + return null; + } + + String[] parts = value.split("\\|", -1); + if (parts.length != EXPECTED_PARTS) { + return null; + } + + String email = parts[0]; + String provider = parts[1]; + String providerId = parts[2]; + + if (!StringUtils.hasText(email) || !StringUtils.hasText(provider)) { + return null; + } + + try { + Provider p = Provider.valueOf(provider); + return new SignupClaims(email, p, StringUtils.hasText(providerId) ? providerId : null); + } catch (Exception e) { + return null; + } + } + + public record SignupClaims(String email, Provider provider, String providerId) { + } +} diff --git a/src/main/java/gg/agit/konect/global/auth/AccessTokenBlacklistService.java b/src/main/java/gg/agit/konect/global/auth/AccessTokenBlacklistService.java new file mode 100644 index 00000000..e63fe457 --- /dev/null +++ b/src/main/java/gg/agit/konect/global/auth/AccessTokenBlacklistService.java @@ -0,0 +1,60 @@ +package gg.agit.konect.global.auth; + +import java.time.Duration; +import java.time.Instant; + +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class AccessTokenBlacklistService { + + private static final String BLACKLIST_PREFIX = "auth:access:blacklist:"; + + private final StringRedisTemplate redis; + + public void blacklist(String token) { + if (!StringUtils.hasText(token)) { + return; + } + + try { + SignedJWT jwt = SignedJWT.parse(token); + JWTClaimsSet claims = jwt.getJWTClaimsSet(); + + String jti = claims.getJWTID(); + if (!StringUtils.hasText(jti) || claims.getExpirationTime() == null) { + return; + } + + Instant exp = claims.getExpirationTime().toInstant(); + Duration ttl = Duration.between(Instant.now(), exp); + if (ttl.isNegative() || ttl.isZero()) { + return; + } + + redis.opsForValue().set(key(jti), "1", ttl); + } catch (Exception ignored) { + // ignore + } + } + + public boolean isBlacklisted(String jti) { + if (!StringUtils.hasText(jti)) { + return false; + } + Boolean exists = redis.hasKey(key(jti)); + return Boolean.TRUE.equals(exists); + } + + private String key(String jti) { + return BLACKLIST_PREFIX + jti; + } +} diff --git a/src/main/java/gg/agit/konect/global/auth/JwtProperties.java b/src/main/java/gg/agit/konect/global/auth/JwtProperties.java new file mode 100644 index 00000000..38826d42 --- /dev/null +++ b/src/main/java/gg/agit/konect/global/auth/JwtProperties.java @@ -0,0 +1,10 @@ +package gg.agit.konect.global.auth; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "app.jwt") +public record JwtProperties( + String secret, + String issuer +) { +} diff --git a/src/main/java/gg/agit/konect/global/auth/JwtProvider.java b/src/main/java/gg/agit/konect/global/auth/JwtProvider.java new file mode 100644 index 00000000..e50fd50d --- /dev/null +++ b/src/main/java/gg/agit/konect/global/auth/JwtProvider.java @@ -0,0 +1,139 @@ +package gg.agit.konect.global.auth; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.util.Date; +import java.util.UUID; + +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.crypto.MACSigner; +import com.nimbusds.jose.crypto.MACVerifier; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; + +import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.global.exception.CustomException; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class JwtProvider { + + private static final int MIN_HS256_SECRET_BYTES = 32; + private static final String CLAIM_USER_ID = "id"; + private static final Duration ACCESS_TOKEN_TTL = Duration.ofMinutes(15); + + private final JwtProperties properties; + private final AccessTokenBlacklistService accessTokenBlacklistService; + + public String createToken(Integer userId) { + if (userId == null) { + throw new IllegalArgumentException("userId is required"); + } + + Instant now = Instant.now(); + Instant expiresAt = now.plus(ACCESS_TOKEN_TTL); + + JWTClaimsSet claims = new JWTClaimsSet.Builder() + .issuer(resolveIssuer()) + .issueTime(Date.from(now)) + .expirationTime(Date.from(expiresAt)) + .jwtID(UUID.randomUUID().toString()) + .claim(CLAIM_USER_ID, userId) + .build(); + + SignedJWT jwt = new SignedJWT(new JWSHeader(JWSAlgorithm.HS256), claims); + + try { + jwt.sign(new MACSigner(resolveSecretBytes())); + } catch (JOSEException e) { + throw new IllegalStateException("Failed to sign access token.", e); + } + + return jwt.serialize(); + } + + public Integer getUserId(String token) { + if (!StringUtils.hasText(token)) { + throw CustomException.of(ApiResponseCode.MISSING_ACCESS_TOKEN); + } + + SignedJWT jwt; + try { + jwt = SignedJWT.parse(token); + } catch (Exception e) { + throw CustomException.of(ApiResponseCode.MALFORMED_ACCESS_TOKEN); + } + + try { + if (!jwt.verify(new MACVerifier(resolveSecretBytes()))) { + throw CustomException.of(ApiResponseCode.INVALID_ACCESS_TOKEN_SIGNATURE); + } + } catch (JOSEException e) { + throw CustomException.of(ApiResponseCode.INVALID_ACCESS_TOKEN_SIGNATURE); + } + + JWTClaimsSet claims; + try { + claims = jwt.getJWTClaimsSet(); + } catch (Exception e) { + throw CustomException.of(ApiResponseCode.INVALID_ACCESS_TOKEN_CLAIMS); + } + + if (!resolveIssuer().equals(claims.getIssuer())) { + throw CustomException.of(ApiResponseCode.INVALID_ACCESS_TOKEN_ISSUER); + } + + Date exp = claims.getExpirationTime(); + if (exp == null) { + throw CustomException.of(ApiResponseCode.INVALID_ACCESS_TOKEN_CLAIMS); + } + + if (Instant.now().isAfter(exp.toInstant())) { + throw CustomException.of(ApiResponseCode.EXPIRED_TOKEN); + } + + String jti = claims.getJWTID(); + if (!StringUtils.hasText(jti)) { + throw CustomException.of(ApiResponseCode.INVALID_ACCESS_TOKEN_CLAIMS); + } + + if (accessTokenBlacklistService.isBlacklisted(jti)) { + throw CustomException.of(ApiResponseCode.BLACKLISTED_ACCESS_TOKEN); + } + + Object id = claims.getClaim(CLAIM_USER_ID); + if (!(id instanceof Number number)) { + throw CustomException.of(ApiResponseCode.INVALID_ACCESS_TOKEN_CLAIMS); + } + + return number.intValue(); + } + + private String resolveIssuer() { + String issuer = properties.issuer(); + if (!StringUtils.hasText(issuer)) { + throw new IllegalStateException("app.jwt.issuer is required"); + } + return issuer; + } + + private byte[] resolveSecretBytes() { + String secret = properties.secret(); + if (!StringUtils.hasText(secret)) { + throw new IllegalStateException("app.jwt.secret is required"); + } + + byte[] bytes = secret.getBytes(StandardCharsets.UTF_8); + if (bytes.length < MIN_HS256_SECRET_BYTES) { + throw new IllegalStateException("app.jwt.secret must be at least 32 bytes"); + } + return bytes; + } +} diff --git a/src/main/java/gg/agit/konect/global/auth/bridge/NativeSessionController.java b/src/main/java/gg/agit/konect/global/auth/bridge/NativeSessionController.java index f4a1d42e..7edd8ddd 100644 --- a/src/main/java/gg/agit/konect/global/auth/bridge/NativeSessionController.java +++ b/src/main/java/gg/agit/konect/global/auth/bridge/NativeSessionController.java @@ -11,6 +11,8 @@ import org.springframework.web.bind.annotation.RestController; import gg.agit.konect.global.auth.annotation.PublicApi; +import gg.agit.konect.domain.user.service.RefreshTokenService; +import gg.agit.konect.global.auth.token.AuthCookieService; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpSession; @@ -25,6 +27,8 @@ public class NativeSessionController { private String frontendBaseUrl; private final NativeSessionBridgeService nativeSessionBridgeService; + private final RefreshTokenService refreshTokenService; + private final AuthCookieService authCookieService; @PublicApi @GetMapping("/native/session/bridge") @@ -52,8 +56,10 @@ public void bridge( existing.invalidate(); } - HttpSession session = request.getSession(true); - session.setAttribute("userId", userId); + authCookieService.clearSignupToken(request, response); + + String refreshToken = refreshTokenService.issue(userId); + authCookieService.setRefreshToken(request, response, refreshToken, refreshTokenService.refreshTtl()); response.sendRedirect(frontendBaseUrl + "/home"); } diff --git a/src/main/java/gg/agit/konect/global/auth/handler/OAuth2LoginSuccessHandler.java b/src/main/java/gg/agit/konect/global/auth/handler/OAuth2LoginSuccessHandler.java index 801c53ed..f951a61b 100644 --- a/src/main/java/gg/agit/konect/global/auth/handler/OAuth2LoginSuccessHandler.java +++ b/src/main/java/gg/agit/konect/global/auth/handler/OAuth2LoginSuccessHandler.java @@ -19,7 +19,11 @@ import gg.agit.konect.domain.user.model.User; import gg.agit.konect.domain.user.repository.UnRegisteredUserRepository; import gg.agit.konect.domain.user.repository.UserRepository; +import gg.agit.konect.domain.user.service.RefreshTokenService; +import gg.agit.konect.domain.user.service.SignupTokenService; import gg.agit.konect.global.auth.bridge.NativeSessionBridgeService; +import gg.agit.konect.global.auth.JwtProvider; +import gg.agit.konect.global.auth.token.AuthCookieService; import gg.agit.konect.global.code.ApiResponseCode; import gg.agit.konect.global.config.SecurityProperties; import gg.agit.konect.global.exception.CustomException; @@ -35,13 +39,16 @@ public class OAuth2LoginSuccessHandler implements AuthenticationSuccessHandler { @Value("${app.frontend.base-url}") private String frontendBaseUrl; - private static final int TEMP_SESSION_EXPIRATION_SECONDS = 600; - private final UserRepository userRepository; private final UnRegisteredUserRepository unRegisteredUserRepository; private final SecurityProperties securityProperties; private final ObjectProvider nativeSessionBridgeService; + private final SignupTokenService signupTokenService; + private final JwtProvider jwtProvider; + private final RefreshTokenService refreshTokenService; + private final AuthCookieService authCookieService; + @Override public void onAuthenticationSuccess( HttpServletRequest request, @@ -89,16 +96,8 @@ private void sendAdditionalInfoRequiredResponse( Provider provider, String providerId ) throws IOException { - HttpSession session = request.getSession(true); - session.setAttribute("email", email); - session.setAttribute("provider", provider); - - if (StringUtils.hasText(providerId)) { - session.setAttribute("providerId", providerId); - } - - session.setMaxInactiveInterval(TEMP_SESSION_EXPIRATION_SECONDS); - + String token = signupTokenService.issue(email, provider, providerId); + authCookieService.setSignupToken(request, response, token, signupTokenService.signupTtl()); response.sendRedirect(frontendBaseUrl + "/signup"); } @@ -107,11 +106,11 @@ private void sendLoginSuccessResponse( HttpServletResponse response, User user ) throws IOException { - HttpSession session = request.getSession(true); - session.setAttribute("userId", user.getId()); - - String redirectUri = (String)session.getAttribute("redirect_uri"); - session.removeAttribute("redirect_uri"); + HttpSession session = request.getSession(false); + String redirectUri = session == null ? null : (String)session.getAttribute("redirect_uri"); + if (session != null) { + session.removeAttribute("redirect_uri"); + } String safeRedirect = resolveSafeRedirect(redirectUri); @@ -122,8 +121,19 @@ private void sendLoginSuccessResponse( String bridgeToken = svc.issue(user.getId()); safeRedirect = appendBridgeToken(safeRedirect, bridgeToken); } + + authCookieService.clearRefreshToken(request, response); + authCookieService.clearSignupToken(request, response); + + response.sendRedirect(safeRedirect); + return; } + String refreshToken = refreshTokenService.issue(user.getId()); + authCookieService.setRefreshToken(request, response, refreshToken, refreshTokenService.refreshTtl()); + + authCookieService.clearSignupToken(request, response); + response.sendRedirect(safeRedirect); } diff --git a/src/main/java/gg/agit/konect/global/auth/interceptor/LoginCheckInterceptor.java b/src/main/java/gg/agit/konect/global/auth/interceptor/LoginCheckInterceptor.java index 09a02140..928fbd93 100644 --- a/src/main/java/gg/agit/konect/global/auth/interceptor/LoginCheckInterceptor.java +++ b/src/main/java/gg/agit/konect/global/auth/interceptor/LoginCheckInterceptor.java @@ -6,18 +6,23 @@ import org.springframework.web.servlet.HandlerInterceptor; import gg.agit.konect.global.auth.annotation.PublicApi; -import gg.agit.konect.global.code.ApiResponseCode; -import gg.agit.konect.global.exception.CustomException; +import gg.agit.konect.global.auth.JwtProvider; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -import jakarta.servlet.http.HttpSession; +import lombok.RequiredArgsConstructor; @Component +@RequiredArgsConstructor public class LoginCheckInterceptor implements HandlerInterceptor { public static final String AUTHENTICATED_USER_ID_ATTRIBUTE = "authenticatedUserId"; public static final String PUBLIC_ENDPOINT_ATTRIBUTE = "publicEndpoint"; + private static final String AUTHORIZATION_HEADER = "Authorization"; + private static final String BEARER_PREFIX = "Bearer "; + + private final JwtProvider jwtProvider; + @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { if (HttpMethod.OPTIONS.matches(request.getMethod())) { @@ -33,13 +38,8 @@ public boolean preHandle(HttpServletRequest request, HttpServletResponse respons return true; } - HttpSession session = request.getSession(false); - Object userId = session == null ? null : session.getAttribute("userId"); - - if (!(userId instanceof Integer)) { - throw CustomException.of(ApiResponseCode.INVALID_SESSION); - } - + String accessToken = resolveBearerToken(request); + Integer userId = jwtProvider.getUserId(accessToken); request.setAttribute(AUTHENTICATED_USER_ID_ATTRIBUTE, userId); return true; @@ -49,4 +49,12 @@ private boolean isPublicEndpoint(HandlerMethod handlerMethod) { return handlerMethod.hasMethodAnnotation(PublicApi.class) || handlerMethod.getBeanType().isAnnotationPresent(PublicApi.class); } + + private String resolveBearerToken(HttpServletRequest request) { + String authorization = request.getHeader(AUTHORIZATION_HEADER); + if (authorization == null || !authorization.startsWith(BEARER_PREFIX)) { + return null; + } + return authorization.substring(BEARER_PREFIX.length()); + } } diff --git a/src/main/java/gg/agit/konect/global/auth/resolver/LoginUserArgumentResolver.java b/src/main/java/gg/agit/konect/global/auth/resolver/LoginUserArgumentResolver.java index 639e4e33..b39bc1b2 100644 --- a/src/main/java/gg/agit/konect/global/auth/resolver/LoginUserArgumentResolver.java +++ b/src/main/java/gg/agit/konect/global/auth/resolver/LoginUserArgumentResolver.java @@ -8,10 +8,10 @@ import org.springframework.web.method.support.ModelAndViewContainer; import gg.agit.konect.global.auth.annotation.UserId; +import gg.agit.konect.global.auth.interceptor.LoginCheckInterceptor; import gg.agit.konect.global.code.ApiResponseCode; import gg.agit.konect.global.exception.CustomException; import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpSession; @Component public class LoginUserArgumentResolver implements HandlerMethodArgumentResolver { @@ -33,14 +33,8 @@ public Object resolveArgument( WebDataBinderFactory binderFactory ) { HttpServletRequest request = (HttpServletRequest)webRequest.getNativeRequest(); - HttpSession session = request.getSession(false); - - if (session == null) { - throw CustomException.of(ApiResponseCode.INVALID_SESSION); - } - - Object userId = session.getAttribute("userId"); + Object userId = request.getAttribute(LoginCheckInterceptor.AUTHENTICATED_USER_ID_ATTRIBUTE); if (!(userId instanceof Integer id)) { throw CustomException.of(ApiResponseCode.INVALID_SESSION); } diff --git a/src/main/java/gg/agit/konect/global/auth/token/AuthCookieService.java b/src/main/java/gg/agit/konect/global/auth/token/AuthCookieService.java new file mode 100644 index 00000000..44d41deb --- /dev/null +++ b/src/main/java/gg/agit/konect/global/auth/token/AuthCookieService.java @@ -0,0 +1,71 @@ +package gg.agit.konect.global.auth.token; + +import java.time.Duration; + +import org.springframework.http.ResponseCookie; +import org.springframework.stereotype.Component; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class AuthCookieService { + + public static final String REFRESH_TOKEN_COOKIE = "refresh_token"; + public static final String SIGNUP_TOKEN_COOKIE = "signup_token"; + + private static final String COOKIE_PATH = "/"; + + public void setRefreshToken(HttpServletRequest request, HttpServletResponse response, String token, Duration ttl) { + ResponseCookie cookie = baseCookie(request, REFRESH_TOKEN_COOKIE, token) + .maxAge(ttl) + .build(); + + response.addHeader("Set-Cookie", cookie.toString()); + } + + public void clearRefreshToken(HttpServletRequest request, HttpServletResponse response) { + ResponseCookie cookie = baseCookie(request, REFRESH_TOKEN_COOKIE, "") + .maxAge(Duration.ZERO) + .build(); + + response.addHeader("Set-Cookie", cookie.toString()); + } + + public void setSignupToken(HttpServletRequest request, HttpServletResponse response, String token, Duration ttl) { + ResponseCookie cookie = baseCookie(request, SIGNUP_TOKEN_COOKIE, token) + .maxAge(ttl) + .build(); + + response.addHeader("Set-Cookie", cookie.toString()); + } + + public void clearSignupToken(HttpServletRequest request, HttpServletResponse response) { + ResponseCookie cookie = baseCookie(request, SIGNUP_TOKEN_COOKIE, "") + .maxAge(Duration.ZERO) + .build(); + + response.addHeader("Set-Cookie", cookie.toString()); + } + + private ResponseCookie.ResponseCookieBuilder baseCookie(HttpServletRequest request, String name, String value) { + ResponseCookie.ResponseCookieBuilder builder = ResponseCookie.from(name, value) + .httpOnly(true) + .secure(isSecureRequest(request)) + .path(COOKIE_PATH); + + return builder; + } + + private boolean isSecureRequest(HttpServletRequest request) { + if (request.isSecure()) { + return true; + } + + String forwardedProto = request.getHeader("X-Forwarded-Proto"); + + return "https".equalsIgnoreCase(forwardedProto); + } +} diff --git a/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java b/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java index 9c9cfb3a..46d75782 100644 --- a/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java +++ b/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java @@ -40,6 +40,13 @@ public enum ApiResponseCode { // 401 Unauthorized INVALID_SESSION(HttpStatus.UNAUTHORIZED, "올바르지 않은 인증 정보 입니다."), + EXPIRED_TOKEN(HttpStatus.UNAUTHORIZED, "만료된 토큰입니다."), + MISSING_ACCESS_TOKEN(HttpStatus.UNAUTHORIZED, "액세스 토큰이 필요합니다."), + MALFORMED_ACCESS_TOKEN(HttpStatus.UNAUTHORIZED, "액세스 토큰 형식이 올바르지 않습니다."), + INVALID_ACCESS_TOKEN_SIGNATURE(HttpStatus.UNAUTHORIZED, "액세스 토큰 서명이 올바르지 않습니다."), + INVALID_ACCESS_TOKEN_ISSUER(HttpStatus.UNAUTHORIZED, "액세스 토큰 발급자가 올바르지 않습니다."), + INVALID_ACCESS_TOKEN_CLAIMS(HttpStatus.UNAUTHORIZED, "액세스 토큰 정보가 올바르지 않습니다."), + BLACKLISTED_ACCESS_TOKEN(HttpStatus.UNAUTHORIZED, "폐기된 액세스 토큰입니다."), // 403 Forbidden (접근 권한 없음) FORBIDDEN_CHAT_ROOM_ACCESS(HttpStatus.FORBIDDEN, "채팅방에 접근할 권한이 없습니다."), @@ -51,6 +58,7 @@ public enum ApiResponseCode { FORBIDDEN_MEMBER_POSITION_CHANGE(HttpStatus.FORBIDDEN, "회원 직책 변경 권한이 없습니다."), FORBIDDEN_POSITION_NAME_CHANGE(HttpStatus.FORBIDDEN, "해당 직책의 이름은 변경할 수 없습니다."), FORBIDDEN_ROLE_ACCESS(HttpStatus.FORBIDDEN, "접근 권한이 없습니다."), + FORBIDDEN_ORIGIN_ACCESS(HttpStatus.FORBIDDEN, "허용되지 않은 Origin 입니다."), // 404 Not Found (리소스를 찾을 수 없음) NO_HANDLER_FOUND(HttpStatus.NOT_FOUND, "유효하지 않은 API 경로입니다."), diff --git a/src/main/java/gg/agit/konect/global/config/SwaggerConfig.java b/src/main/java/gg/agit/konect/global/config/SwaggerConfig.java index 9c32f24b..24549bea 100644 --- a/src/main/java/gg/agit/konect/global/config/SwaggerConfig.java +++ b/src/main/java/gg/agit/konect/global/config/SwaggerConfig.java @@ -8,8 +8,11 @@ import org.springdoc.core.models.GroupedOpenApi; +import io.swagger.v3.oas.models.Components; import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; import io.swagger.v3.oas.models.servers.Server; @Configuration @@ -25,12 +28,24 @@ public SwaggerConfig( @Bean public OpenAPI openAPI() { + String jwt = "Jwt Authentication"; + SecurityRequirement securityRequirement = new SecurityRequirement().addList(jwt); + Components components = new Components().addSecuritySchemes(jwt, new SecurityScheme() + .name(jwt) + .type(SecurityScheme.Type.HTTP) + .scheme("Bearer") + .description("토큰값을 입력하여 인증을 활성화할 수 있습니다.") + .bearerFormat("JWT") + ); + Server server = new Server(); server.setUrl(serverUrl); return new OpenAPI() .openapi("3.1.0") .info(apiInfo()) + .addSecurityItem(securityRequirement) + .components(components) .addServersItem(server); } diff --git a/src/main/java/gg/agit/konect/global/config/WebConfig.java b/src/main/java/gg/agit/konect/global/config/WebConfig.java index 7ceea7e4..71b3b382 100644 --- a/src/main/java/gg/agit/konect/global/config/WebConfig.java +++ b/src/main/java/gg/agit/konect/global/config/WebConfig.java @@ -31,6 +31,7 @@ public void addCorsMappings(CorsRegistry registry) { .allowedOrigins(corsProperties.allowedOrigins().toArray(new String[0])) .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH") .allowedHeaders("*") + .exposedHeaders("Authorization") .allowCredentials(true) .maxAge(CORS_PREFLIGHT_MAX_AGE_SECONDS); } diff --git a/src/main/resources/config b/src/main/resources/config index b421a40e..513e3cf6 160000 --- a/src/main/resources/config +++ b/src/main/resources/config @@ -1 +1 @@ -Subproject commit b421a40e7db37a33fa0933319764afed4c0889c2 +Subproject commit 513e3cf67f61e76d6656d68069c17d3b1b1e52a0