Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 27 additions & 6 deletions auth/src/main/java/com/nalssilog/auth/api/AuthController.java
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,13 @@ public MeResponse me(@AuthenticationPrincipal Long memberId, HttpServletRequest
return MeResponse.linkRequired(link.get().provider(), link.get().email(), link.get().existingProviders());
}

// AT 쿠키는 JWT와 같은 시점에 브라우저에서 사라질 수 있다. RT가 남아 있으면 로그아웃(NONE)이 아니라
// refresh가 필요한 상태이므로 401로 명확히 알린다. NONE은 인증 쿠키가 정말 하나도 없을 때만 반환한다.
if (cookieManager.readAccessToken(request).isPresent()
|| cookieManager.readRefreshToken(request).isPresent()) {
throw new NalssiLogException(AuthErrorCode.AUTH_ACCESS_TOKEN_EXPIRED);
}

return MeResponse.none();
}

Expand All @@ -92,7 +99,8 @@ public MeResponse signup(@Valid @RequestBody SignupRequest request,
request.agreedTerms());
TokenPair tokens = authTokenService.issue(
member.id(), member.status(), ticket.provider(), deviceInfoResolver.resolve(httpRequest));
cookieManager.addAuthCookies(response, tokens.accessToken(), tokens.refreshToken());
cookieManager.addAuthCookies(
response, tokens.accessToken(), tokens.refreshToken(), tokens.refreshTokenMaxAge());
ticketStore.deleteSignup(ticketId);
cookieManager.clearSignupTicketCookie(response);

Expand All @@ -101,11 +109,19 @@ public MeResponse signup(@Valid @RequestBody SignupRequest request,

@PostMapping("/refresh")
public void refresh(HttpServletRequest request, HttpServletResponse response) {
String refreshToken = cookieManager.readRefreshToken(request)
.orElseThrow(() -> new NalssiLogException(AuthErrorCode.AUTH_SESSION_EXPIRED));
TokenPair tokens = authTokenService.refresh(refreshToken, deviceInfoResolver.resolve(request));

cookieManager.addAuthCookies(response, tokens.accessToken(), tokens.refreshToken());
try {
String refreshToken = cookieManager.readRefreshToken(request)
.orElseThrow(() -> new NalssiLogException(AuthErrorCode.AUTH_SESSION_EXPIRED));
TokenPair tokens = authTokenService.refresh(refreshToken, deviceInfoResolver.resolve(request));

cookieManager.addAuthCookies(
response, tokens.accessToken(), tokens.refreshToken(), tokens.refreshTokenMaxAge());
} catch (NalssiLogException exception) {
if (isTerminalRefreshError(exception)) {
cookieManager.clearAuthCookies(response);
}
throw exception;
}
}

@PostMapping("/logout")
Expand Down Expand Up @@ -214,4 +230,9 @@ private void validateProvider(String provider) {
throw new NalssiLogException(AuthErrorCode.UNSUPPORTED_PROVIDER);
}
}

private boolean isTerminalRefreshError(NalssiLogException exception) {
return exception.getErrorCode() == AuthErrorCode.AUTH_SESSION_EXPIRED
|| exception.getErrorCode() == AuthErrorCode.AUTH_REFRESH_REUSED;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.nalssilog.auth.application.dto.SessionData;
import com.nalssilog.auth.application.dto.SessionView;
import com.nalssilog.auth.config.AuthProperties;
import com.nalssilog.auth.domain.AuthErrorCode;
import com.nalssilog.auth.repository.RefreshTokenStore;
import com.nalssilog.common.exception.NalssiLogException;
Expand All @@ -19,6 +20,7 @@
public class AuthSessionService {

private final RefreshTokenStore refreshTokenStore;
private final AuthProperties properties;

public List<SessionView> listSessions(Long memberId, String currentTokenHash) {
return refreshTokenStore.findSessionsByMember(memberId).stream()
Expand All @@ -37,13 +39,17 @@ public List<SessionView> listSessions(Long memberId, String currentTokenHash) {
* 특정 세션(기기) 로그아웃. 대상이 현재 세션이면 true 를 반환해 컨트롤러가 쿠키까지 정리하게 한다.
*/
public boolean revokeSession(Long memberId, String sessionId, String currentTokenHash) {
SessionData target = refreshTokenStore.findSessionsByMember(memberId).stream()
List<SessionData> sessions = refreshTokenStore.findSessionsByMember(memberId);
SessionData target = sessions.stream()
.filter(session -> session.sessionId().equals(sessionId))
.findFirst()
.orElseThrow(() -> new NalssiLogException(AuthErrorCode.SESSION_NOT_FOUND));

refreshTokenStore.delete(target.tokenHash());
boolean current = currentTokenHash != null && sessions.stream()
.anyMatch(session -> session.sessionId().equals(sessionId)
&& session.tokenHash().equals(currentTokenHash));
refreshTokenStore.revokeSession(memberId, target.sessionId(), properties.jwt().refreshTokenTtl());

return target.tokenHash().equals(currentTokenHash);
return current;
}
}
189 changes: 167 additions & 22 deletions auth/src/main/java/com/nalssilog/auth/application/AuthTokenService.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
import com.nalssilog.auth.config.AuthProperties;
import com.nalssilog.auth.domain.AuthErrorCode;
import com.nalssilog.auth.repository.RefreshTokenStore;
import com.nalssilog.auth.repository.RefreshTokenStore.RotationResult;
import com.nalssilog.auth.repository.RefreshTokenStore.RotationStatus;
import com.nalssilog.common.exception.NalssiLogException;
import com.nalssilog.member.application.dto.MemberInfo;
import com.nalssilog.member.domain.MemberStatus;
Expand All @@ -14,18 +16,23 @@
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.time.Duration;
import java.time.Instant;
import java.util.Base64;
import java.util.HexFormat;
import java.util.Optional;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

@Slf4j
@Service
@RequiredArgsConstructor
public class AuthTokenService {

private static final SecureRandom SECURE_RANDOM = new SecureRandom();
private static final int FINGERPRINT_LENGTH = 12;

private final JwtTokenProvider jwtTokenProvider;
private final RefreshTokenStore refreshTokenStore;
Expand All @@ -34,29 +41,63 @@ public class AuthTokenService {

/** 새 로그인 세션 발급(새 sessionId + 기기 정보 저장). */
public TokenPair issue(Long memberId, MemberStatus status, Provider provider, DeviceInfo device) {
return issueWithSession(
memberId, status, provider, UUID.randomUUID().toString(), Instant.now(), device);
String sessionId = UUID.randomUUID().toString();
TokenPair tokens = issueWithSession(memberId, status, provider, sessionId, Instant.now(), device);
log.info("auth.refresh.issued memberId={} sessionId={} provider={}", memberId, sessionId, provider);
return tokens;
}

/** rotation: 토큰만 교체하고 기기 정체성(sessionId·loginAt·deviceName)은 유지, lastActiveAt·ip 갱신. */
/**
* RT rotation. Redis가 기존 RT 소비와 새 RT 저장을 원자적으로 처리한다.
* retry grace 안의 중복 요청은 최초 요청과 같은 새 RT로 수렴하고, grace 이후 재사용은 해당 기기 세션을 폐기한다.
*/
public TokenPair refresh(String refreshToken, DeviceInfo device) {
String currentHash = hash(refreshToken);
SessionData current = refreshTokenStore.findSession(currentHash)
.orElseThrow(() -> new NalssiLogException(AuthErrorCode.AUTH_SESSION_EXPIRED));

MemberInfo member = memberClient.findMemberInfo(current.memberId())
.orElseThrow(() -> new NalssiLogException(AuthErrorCode.AUTH_SESSION_EXPIRED));

refreshTokenStore.delete(currentHash);

DeviceInfo preserved = new DeviceInfo(current.deviceName(), device.ip());
Optional<SessionData> current = refreshTokenStore.findSession(currentHash);
if (current.isEmpty()) {
return resolveInactiveRefresh(currentHash);
}

return issueWithSession(
member.id(), member.status(), current.provider(), current.sessionId(), current.loginAt(), preserved);
SessionData currentSession = current.get();
MemberInfo member = activeMember(currentSession.memberId(), currentSession.sessionId(), currentHash);
String replacementToken = generateRefreshToken();
String replacementHash = hash(replacementToken);
DeviceInfo preserved = new DeviceInfo(currentSession.deviceName(), device.ip());
SessionData replacement = sessionData(
replacementHash,
member.id(),
currentSession.provider(),
currentSession.sessionId(),
currentSession.loginAt(),
preserved);

RotationResult result = refreshTokenStore.rotate(
currentHash,
replacementToken,
replacement,
properties.jwt().refreshTokenTtl(),
properties.refresh().retryGrace());

return switch (result.status()) {
case ROTATED -> {
log.info("auth.refresh.rotated memberId={} sessionId={} token={}",
member.id(), currentSession.sessionId(), fingerprint(currentHash));
yield tokenPair(
member, currentSession.provider(), replacementToken, properties.jwt().refreshTokenTtl());
}
case RETRIED -> retry(result, currentHash);
case REUSED -> rejectReuse(result, currentHash);
case REVOKED, MISSING -> rejectExpired(currentHash, result.status().name());
};
}

public void revoke(String refreshToken) {
refreshTokenStore.delete(hash(refreshToken));
String tokenHash = hash(refreshToken);
refreshTokenStore.revokeByTokenHash(tokenHash, properties.jwt().refreshTokenTtl())
.ifPresentOrElse(
session -> log.info("auth.refresh.revoked memberId={} sessionId={} reason=logout token={}",
session.memberId(), session.sessionId(), fingerprint(tokenHash)),
() -> log.info("auth.refresh.revoke_skipped reason=not_found token={}", fingerprint(tokenHash)));
}

/** refresh 토큰의 세션 키(해시). '현재 세션' 판별용. */
Expand All @@ -66,33 +107,137 @@ public String tokenHash(String refreshToken) {

/** 해당 회원의 모든 refresh 세션 강제 만료(전 기기). 탈퇴 시 사용. */
public void revokeAllSessions(Long memberId) {
refreshTokenStore.deleteAllByMember(memberId);
long revoked = refreshTokenStore.deleteAllByMember(memberId, properties.jwt().refreshTokenTtl());
log.info("auth.refresh.revoked_all memberId={} sessions={} reason=withdrawal", memberId, revoked);
}

private TokenPair resolveInactiveRefresh(String currentHash) {
RefreshTokenStore.UsedToken used = refreshTokenStore.findUsedToken(currentHash).orElse(null);
if (used == null) {
return rejectExpired(currentHash, "not_found_or_expired");
}
if (refreshTokenStore.isSessionRevoked(used.sessionId())) {
return rejectExpired(currentHash, "session_revoked");
}

// retry key의 실제 존재 여부와 replacement 활성 여부는 원자 rotate 스크립트가 최종 판정한다.
SessionData replacement = refreshTokenStore.findSession(used.replacementHash()).orElse(null);
if (replacement == null) {
return rejectReuse(
new RotationResult(RotationStatus.REUSED, "", used.replacementHash(),
used.memberId(), used.sessionId(), 0),
currentHash);
}

// 후보 세션으로 rotate를 다시 호출하면 old key가 없으므로 Lua가 retry key 유무에 따라 RETRIED/REUSED를 판정한다.
RotationResult result = refreshTokenStore.rotate(
currentHash,
"",
replacement,
properties.jwt().refreshTokenTtl(),
properties.refresh().retryGrace());

return switch (result.status()) {
case RETRIED -> retry(result, currentHash);
case REUSED -> rejectReuse(result, currentHash);
case REVOKED, MISSING -> rejectExpired(currentHash, result.status().name());
case ROTATED -> throw new IllegalStateException("inactive refresh token was unexpectedly rotated");
};
}

private TokenPair retry(RotationResult result, String currentHash) {
SessionData replacement = refreshTokenStore.findSession(result.replacementHash())
.orElseThrow(() -> expired(currentHash, "replacement_missing"));
MemberInfo member = activeMember(replacement.memberId(), replacement.sessionId(), currentHash);
if (result.refreshTokenTtlMillis() < 1_000) {
throw expired(currentHash, "replacement_expiring");
}

log.info("auth.refresh.retry_replayed memberId={} sessionId={} token={}",
member.id(), replacement.sessionId(), fingerprint(currentHash));
return tokenPair(
member,
replacement.provider(),
result.replacementToken(),
Duration.ofMillis(result.refreshTokenTtlMillis()));
}

private TokenPair rejectReuse(RotationResult result, String currentHash) {
if (result.memberId() != null && result.sessionId() != null && !result.sessionId().isBlank()) {
long revoked = refreshTokenStore.revokeSession(
result.memberId(), result.sessionId(), properties.jwt().refreshTokenTtl());
log.warn("auth.refresh.reuse_detected memberId={} sessionId={} revokedTokens={} token={}",
result.memberId(), result.sessionId(), revoked, fingerprint(currentHash));
} else {
log.warn("auth.refresh.reuse_detected memberId=unknown sessionId=unknown token={}",
fingerprint(currentHash));
}

throw new NalssiLogException(AuthErrorCode.AUTH_REFRESH_REUSED);
}

private TokenPair rejectExpired(String currentHash, String reason) {
throw expired(currentHash, reason);
}

private NalssiLogException expired(String currentHash, String reason) {
log.info("auth.refresh.rejected reason={} token={}", reason, fingerprint(currentHash));
return new NalssiLogException(AuthErrorCode.AUTH_SESSION_EXPIRED);
}

private MemberInfo activeMember(Long memberId, String sessionId, String tokenHash) {
return memberClient.findMemberInfo(memberId).orElseThrow(() -> {
refreshTokenStore.revokeSession(memberId, sessionId, properties.jwt().refreshTokenTtl());
return expired(tokenHash, "member_missing");
});
}

private TokenPair issueWithSession(Long memberId, MemberStatus status, Provider provider, String sessionId,
Instant loginAt, DeviceInfo device) {
String accessToken = jwtTokenProvider.createAccessToken(memberId, status, provider);
String refreshToken = generateRefreshToken();
String tokenHash = hash(refreshToken);
SessionData session = new SessionData(
tokenHash, sessionId, memberId, provider, device.deviceName(), device.ip(), loginAt, Instant.now());
SessionData session = sessionData(tokenHash, memberId, provider, sessionId, loginAt, device);

refreshTokenStore.save(tokenHash, session, properties.jwt().refreshTokenTtl());
return new TokenPair(
jwtTokenProvider.createAccessToken(memberId, status, provider),
refreshToken,
properties.jwt().refreshTokenTtl());
}

private SessionData sessionData(String tokenHash, Long memberId, Provider provider, String sessionId,
Instant loginAt, DeviceInfo device) {
return new SessionData(
tokenHash,
sessionId,
memberId,
provider,
device.deviceName(),
device.ip(),
loginAt,
Instant.now());
}

return new TokenPair(accessToken, refreshToken);
private TokenPair tokenPair(MemberInfo member, Provider provider, String refreshToken, Duration maxAge) {
return new TokenPair(
jwtTokenProvider.createAccessToken(member.id(), member.status(), provider),
refreshToken,
maxAge);
}

private String generateRefreshToken() {
byte[] bytes = new byte[32];
SECURE_RANDOM.nextBytes(bytes);

return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}

private String fingerprint(String tokenHash) {
return tokenHash.substring(0, Math.min(FINGERPRINT_LENGTH, tokenHash.length()));
}

private String hash(String token) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");

return HexFormat.of().formatHex(digest.digest(token.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 not available", e);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
package com.nalssilog.auth.application;

public record TokenPair(String accessToken, String refreshToken) {
import java.time.Duration;

public record TokenPair(String accessToken, String refreshToken, Duration refreshTokenMaxAge) {

public TokenPair(String accessToken, String refreshToken) {
this(accessToken, refreshToken, null);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,16 @@ public void addAccessTokenCookie(HttpServletResponse response, String accessToke
}

public void addAuthCookies(HttpServletResponse response, String accessToken, String refreshToken) {
addAuthCookies(response, accessToken, refreshToken, properties.jwt().refreshTokenTtl());
}

public void addAuthCookies(HttpServletResponse response, String accessToken, String refreshToken,
Duration refreshTokenMaxAge) {
addAccessTokenCookie(response, accessToken);
addCookie(response, REFRESH_TOKEN_COOKIE, refreshToken, properties.jwt().refreshTokenTtl());
Duration maxAge = refreshTokenMaxAge == null
? properties.jwt().refreshTokenTtl()
: refreshTokenMaxAge;
addCookie(response, REFRESH_TOKEN_COOKIE, refreshToken, maxAge);
}

public void clearAuthCookies(HttpServletResponse response) {
Expand Down
Loading