Skip to content

Commit 9415405

Browse files
authored
[refactor] ActiveSession 엔티티를 추가하여, 사용자의 다중 로그인을 방지
1 parent e4b6a87 commit 9415405

20 files changed

Lines changed: 920 additions & 204 deletions

backend/src/main/java/com/back/api/auth/service/AuthService.java

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import java.time.LocalDate;
44

5+
import org.apache.commons.lang3.StringUtils;
56
import org.springframework.security.crypto.password.PasswordEncoder;
67
import org.springframework.stereotype.Service;
78
import org.springframework.transaction.annotation.Transactional;
@@ -12,7 +13,9 @@
1213
import com.back.api.auth.dto.response.AuthResponse;
1314
import com.back.api.auth.dto.response.TokenResponse;
1415
import com.back.api.auth.dto.response.UserResponse;
16+
import com.back.domain.auth.entity.ActiveSession;
1517
import com.back.domain.auth.entity.RefreshToken;
18+
import com.back.domain.auth.repository.ActiveSessionRepository;
1619
import com.back.domain.auth.repository.RefreshTokenRepository;
1720
import com.back.domain.user.entity.User;
1821
import com.back.domain.user.entity.UserActiveStatus;
@@ -33,6 +36,7 @@ public class AuthService {
3336
private final AuthTokenService authTokenService;
3437
private final HttpRequestContext requestContext;
3538
private final RefreshTokenRepository refreshTokenRepository;
39+
private final ActiveSessionRepository activeSessionRepository;
3640

3741
@Transactional
3842
public AuthResponse signup(SignupRequest request) {
@@ -59,7 +63,7 @@ public AuthResponse signup(SignupRequest request) {
5963

6064
User savedUser = userRepository.save(user);
6165

62-
JwtDto tokens = authTokenService.generateTokens(savedUser);
66+
JwtDto tokens = setSessionAndCookie(savedUser);
6367

6468
return buildAuthResponse(savedUser, tokens);
6569
}
@@ -73,25 +77,20 @@ public AuthResponse login(LoginRequest request) {
7377
throw new ErrorException(AuthErrorCode.LOGIN_FAILED);
7478
}
7579

76-
JwtDto tokens = authTokenService.generateTokens(user);
77-
78-
requestContext.setAccessTokenCookie(tokens.accessToken());
79-
requestContext.setRefreshTokenCookie(tokens.refreshToken());
80+
JwtDto tokens = setSessionAndCookie(user);
8081

8182
return buildAuthResponse(user, tokens);
8283
}
8384

8485
@Transactional
8586
public void logout() {
8687
String refreshTokenStr = requestContext.getCookieValue("refreshToken", null);
87-
if (refreshTokenStr == null || refreshTokenStr.isBlank()) {
88+
if (StringUtils.isBlank(refreshTokenStr)) {
8889
throw new ErrorException(AuthErrorCode.REFRESH_TOKEN_REQUIRED);
8990
}
9091

91-
User currentUser = requestContext.getUser();
92-
9392
RefreshToken refreshToken = refreshTokenRepository
94-
.findByTokenAndUserIdAndRevokedFalse(refreshTokenStr, currentUser.getId())
93+
.findByTokenAndUserIdAndRevokedFalse(refreshTokenStr, requestContext.getUserId())
9594
.orElseThrow(() -> new ErrorException(AuthErrorCode.REFRESH_TOKEN_NOT_FOUND));
9695

9796
refreshToken.revoke();
@@ -108,6 +107,22 @@ public void verifyPassword(String rawPassword) {
108107
}
109108
}
110109

110+
private JwtDto setSessionAndCookie(User user) {
111+
ActiveSession session = activeSessionRepository.findByUserIdForUpdate(user.getId())
112+
.orElseGet(() -> activeSessionRepository.save(ActiveSession.create(user)));
113+
114+
session.rotate();
115+
116+
refreshTokenRepository.revokeAllActiveByUserId(user.getId());
117+
118+
JwtDto tokens = authTokenService.issueTokens(user, session.getSessionId(), session.getTokenVersion());
119+
120+
requestContext.setAccessTokenCookie(tokens.accessToken());
121+
requestContext.setRefreshTokenCookie(tokens.refreshToken());
122+
123+
return tokens;
124+
}
125+
111126
private AuthResponse buildAuthResponse(User user, JwtDto tokens) {
112127
TokenResponse tokenResponse = new TokenResponse(
113128
tokens.tokenType(),

backend/src/main/java/com/back/api/auth/service/AuthTokenService.java

Lines changed: 54 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,22 @@
11
package com.back.api.auth.service;
22

33
import java.time.LocalDateTime;
4-
import java.util.Map;
54

5+
import org.apache.commons.lang3.StringUtils;
66
import org.springframework.stereotype.Service;
7+
import org.springframework.transaction.annotation.Transactional;
78

89
import com.back.api.auth.dto.JwtDto;
10+
import com.back.domain.auth.entity.ActiveSession;
911
import com.back.domain.auth.entity.RefreshToken;
12+
import com.back.domain.auth.repository.ActiveSessionRepository;
1013
import com.back.domain.auth.repository.RefreshTokenRepository;
1114
import com.back.domain.user.entity.User;
15+
import com.back.domain.user.repository.UserRepository;
16+
import com.back.global.error.code.AuthErrorCode;
17+
import com.back.global.error.exception.ErrorException;
1218
import com.back.global.http.HttpRequestContext;
19+
import com.back.global.security.JwtClaims;
1320
import com.back.global.security.JwtProvider;
1421

1522
import lombok.RequiredArgsConstructor;
@@ -20,11 +27,14 @@ public class AuthTokenService {
2027

2128
private final JwtProvider jwtProvider;
2229
private final RefreshTokenRepository tokenRepository;
30+
private final ActiveSessionRepository activeSessionRepository;
31+
private final UserRepository userRepository;
2332
private final HttpRequestContext requestContext;
2433

25-
public JwtDto generateTokens(User user) {
26-
String accessToken = jwtProvider.generateAccessToken(user);
27-
String refreshTokenStr = jwtProvider.generateRefreshToken(user);
34+
@Transactional
35+
public JwtDto issueTokens(User user, String sessionId, long tokenVersion) {
36+
String accessToken = jwtProvider.generateAccessToken(user, sessionId, tokenVersion);
37+
String refreshTokenStr = jwtProvider.generateRefreshToken(user, sessionId, tokenVersion);
2838

2939
// === seconds 기준 ===
3040
long accessValiditySeconds = jwtProvider.getAccessTokenValiditySeconds();
@@ -37,17 +47,16 @@ public JwtDto generateTokens(User user) {
3747
// === DB 저장용 만료 시각 (LocalDateTime) ===
3848
LocalDateTime expiresAt = issuedAt.plusSeconds(refreshValiditySeconds);
3949

40-
String userAgent = requestContext.getUserAgent();
41-
String ip = requestContext.getClientIp();
42-
4350
RefreshToken refreshToken = RefreshToken.builder()
4451
.user(user)
4552
.token(refreshTokenStr)
4653
.issuedAt(issuedAt)
4754
.expiresAt(expiresAt)
4855
.revoked(false)
49-
.userAgent(userAgent)
50-
.ipAddress(ip)
56+
.userAgent(requestContext.getUserAgent())
57+
.ipAddress(requestContext.getClientIp())
58+
.sessionId(sessionId)
59+
.tokenVersion(tokenVersion)
5160
.build();
5261

5362
tokenRepository.save(refreshToken);
@@ -67,7 +76,41 @@ public JwtDto generateTokens(User user) {
6776
);
6877
}
6978

70-
public Map<String, Object> payloadOrNull(String accessToken) {
71-
return jwtProvider.payloadOrNull(accessToken);
79+
@Transactional
80+
public JwtDto rotateTokenByRefreshToken(String refreshTokenStr) {
81+
if (StringUtils.isBlank(refreshTokenStr)) {
82+
throw new ErrorException(AuthErrorCode.REFRESH_TOKEN_REQUIRED);
83+
}
84+
85+
if (jwtProvider.isExpired(refreshTokenStr)) {
86+
throw new ErrorException(AuthErrorCode.TOKEN_EXPIRED);
87+
}
88+
89+
JwtClaims claims = jwtProvider.payloadOrNull(refreshTokenStr);
90+
91+
if (claims == null || !"refresh".equals(claims.tokenType())) {
92+
throw new ErrorException(AuthErrorCode.INVALID_TOKEN);
93+
}
94+
95+
long userId = claims.userId();
96+
String sid = claims.sessionId();
97+
long tokenVersion = claims.tokenVersion();
98+
99+
ActiveSession active = activeSessionRepository.findByUserIdForUpdate(userId)
100+
.orElseThrow(() -> new ErrorException(AuthErrorCode.UNAUTHORIZED));
101+
102+
if (!active.getSessionId().equals(sid) || active.getTokenVersion() != tokenVersion) {
103+
throw new ErrorException(AuthErrorCode.ACCESS_OTHER_DEVICE);
104+
}
105+
106+
int revoked = tokenRepository.revokeIfActive(refreshTokenStr);
107+
if (revoked != 1) {
108+
throw new ErrorException(AuthErrorCode.REFRESH_TOKEN_NOT_FOUND);
109+
}
110+
111+
User user = userRepository.findById(userId)
112+
.orElseThrow(() -> new ErrorException(AuthErrorCode.UNAUTHORIZED));
113+
114+
return issueTokens(user, active.getSessionId(), active.getTokenVersion());
72115
}
73116
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
package com.back.domain.auth.entity;
2+
3+
import java.time.LocalDateTime;
4+
import java.util.UUID;
5+
6+
import com.back.domain.user.entity.User;
7+
import com.back.global.entity.BaseEntity;
8+
9+
import jakarta.persistence.Column;
10+
import jakarta.persistence.Entity;
11+
import jakarta.persistence.FetchType;
12+
import jakarta.persistence.GeneratedValue;
13+
import jakarta.persistence.Id;
14+
import jakarta.persistence.Index;
15+
import jakarta.persistence.JoinColumn;
16+
import jakarta.persistence.OneToOne;
17+
import jakarta.persistence.Table;
18+
import jakarta.persistence.UniqueConstraint;
19+
import lombok.AccessLevel;
20+
import lombok.Builder;
21+
import lombok.Getter;
22+
import lombok.NoArgsConstructor;
23+
24+
@Entity
25+
@Table(
26+
name = "activeSessions",
27+
uniqueConstraints = {
28+
@UniqueConstraint(name = "uk_active_session_user", columnNames = {"user_id"})
29+
},
30+
indexes = {
31+
@Index(name = "idx_active_session_session_id", columnList = "session_id")
32+
}
33+
)
34+
@Getter
35+
@NoArgsConstructor(access = AccessLevel.PROTECTED)
36+
public class ActiveSession extends BaseEntity {
37+
38+
@Id
39+
@GeneratedValue
40+
private Long id;
41+
42+
@OneToOne(fetch = FetchType.LAZY)
43+
@JoinColumn(name = "user_id", nullable = false)
44+
private User user;
45+
46+
@Column(name = "session_id", nullable = false, length = 36)
47+
private String sessionId;
48+
49+
@Column(name = "token_version", nullable = false)
50+
private long tokenVersion;
51+
52+
@Column(name = "last_login_at", nullable = false)
53+
private LocalDateTime lastLoginAt;
54+
55+
@Builder
56+
private ActiveSession(
57+
User user,
58+
String sessionId,
59+
long tokenVersion,
60+
LocalDateTime lastLoginAt
61+
) {
62+
this.user = user;
63+
this.sessionId = sessionId;
64+
this.tokenVersion = tokenVersion;
65+
this.lastLoginAt = lastLoginAt;
66+
}
67+
68+
public static ActiveSession create(User user) {
69+
return ActiveSession.builder()
70+
.user(user)
71+
.sessionId(UUID.randomUUID().toString())
72+
.tokenVersion(1L)
73+
.lastLoginAt(LocalDateTime.now())
74+
.build();
75+
}
76+
77+
public void rotate() {
78+
this.sessionId = UUID.randomUUID().toString();
79+
this.tokenVersion += 1;
80+
this.lastLoginAt = LocalDateTime.now();
81+
}
82+
}

backend/src/main/java/com/back/domain/auth/entity/RefreshToken.java

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,12 @@ public class RefreshToken extends BaseEntity {
4949
@Column(name = "expires_at")
5050
private LocalDateTime expiresAt;
5151

52+
@Column(name = "session_id", length = 36)
53+
private String sessionId;
54+
55+
@Column(name = "token_version")
56+
private Long tokenVersion;
57+
5258
private boolean revoked; // 기기 로그아웃 확인
5359

5460
private String userAgent;
@@ -61,6 +67,8 @@ private RefreshToken(
6167
String token,
6268
LocalDateTime issuedAt,
6369
LocalDateTime expiresAt,
70+
String sessionId,
71+
long tokenVersion,
6472
boolean revoked,
6573
String userAgent,
6674
String ipAddress
@@ -69,6 +77,8 @@ private RefreshToken(
6977
this.token = token;
7078
this.issuedAt = issuedAt;
7179
this.expiresAt = expiresAt;
80+
this.sessionId = sessionId;
81+
this.tokenVersion = tokenVersion;
7282
this.revoked = revoked;
7383
this.userAgent = userAgent;
7484
this.ipAddress = ipAddress;
@@ -77,14 +87,4 @@ private RefreshToken(
7787
public void revoke() {
7888
this.revoked = true;
7989
}
80-
81-
public void updateRefreshToken(
82-
String newToken,
83-
LocalDateTime newIssuedAt,
84-
LocalDateTime newExpiresAt
85-
) {
86-
this.token = newToken;
87-
this.issuedAt = newIssuedAt;
88-
this.expiresAt = newExpiresAt;
89-
}
9090
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package com.back.domain.auth.repository;
2+
3+
import java.util.Optional;
4+
5+
import org.springframework.data.jpa.repository.JpaRepository;
6+
import org.springframework.data.jpa.repository.Lock;
7+
import org.springframework.data.jpa.repository.Query;
8+
import org.springframework.data.repository.query.Param;
9+
10+
import com.back.domain.auth.entity.ActiveSession;
11+
12+
import jakarta.persistence.LockModeType;
13+
14+
public interface ActiveSessionRepository extends JpaRepository<ActiveSession, Long> {
15+
16+
@Lock(LockModeType.PESSIMISTIC_WRITE)
17+
@Query("SELECT s FROM ActiveSession s WHERE s.user.id = :userId")
18+
Optional<ActiveSession> findByUserIdForUpdate(@Param("userId") long userId);
19+
20+
Optional<ActiveSession> findByUserId(long userId);
21+
}

backend/src/main/java/com/back/domain/auth/repository/RefreshTokenRepository.java

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,5 +21,22 @@ public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long
2121
@Query("update RefreshToken rt set rt.revoked = true where rt.user.id = :userId and rt.revoked = false")
2222
int revokeAllByUserId(@Param("userId") Long userId);
2323

24+
@Modifying
25+
@Query("""
26+
UPDATE RefreshToken rt
27+
set rt.revoked = true
28+
where rt.token = :token
29+
and rt.revoked = false
30+
""")
31+
int revokeIfActive(@Param("token") String token);
32+
33+
@Modifying(clearAutomatically = true, flushAutomatically = true)
34+
@Query("""
35+
update RefreshToken rt
36+
set rt.revoked = true
37+
where rt.user.id = :userId and rt.revoked = false
38+
""")
39+
int revokeAllActiveByUserId(@Param("userId") long userId);
40+
2441
Optional<RefreshToken> findByUserIdAndRevokedFalse(Long userId);
2542
}

backend/src/main/java/com/back/global/error/code/AuthErrorCode.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ public enum AuthErrorCode implements ErrorCode {
1212
ALREADY_EXIST_NICKNAME(HttpStatus.BAD_REQUEST, "이미 존재하는 닉네임입니다."),
1313

1414
UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "로그인 후 이용해주세요."),
15+
ACCESS_OTHER_DEVICE(HttpStatus.UNAUTHORIZED, "다른 환경에서 로그인했습니다."),
1516

1617
FORBIDDEN(HttpStatus.FORBIDDEN, "접근 권한이 없습니다."),
1718
ADMIN_ONLY(HttpStatus.FORBIDDEN, "관리자 계정만 접근 가능합니다."),

0 commit comments

Comments
 (0)