Nhiều thiết bị, thu hồi token, phiên đồng thờiMultiple devices, token revocation, concurrent sessions
Mỗi lần đăng nhập tạo một dòng user_session; access token và refresh token đều mang sid của dòng đó. Gateway kiểm tra JWT rồi tra revoked:{sid} trong Redis. Session Service quản lý danh sách, thu hồi và giới hạn phiên, phát sự kiện cho bộ gửi cảnh báo. Bật “Vượt giới hạn 2 phiên” để xem phiên cũ nhất bị đẩy ra, “Hết idle timeout” để xem phiên hết hạn vì không dùng.Each sign-in creates a user_session row; both the access and refresh tokens carry its sid. The gateway verifies the JWT, then looks up revoked:{sid} in Redis. The session service handles listing, revocation and limits, and publishes events for the security notifier. Turn on “Over the 2-session limit” to see the oldest session evicted, and “Idle timeout” to see a session expire from inactivity.
1Trên MacBook, người dùng mở trang Bảo mật. Có 3 phiên: MacBook (phiên hiện tại), iPhone, và một máy Windows lạ ở TP.HCM.On the MacBook the user opens the Security page. There are 3 sessions: the MacBook (current), an iPhone, and an unfamiliar Windows PC in Ho Chi Minh City.
Đặt sid vào claim của access token. Khi thu hồi, ghi revoked:{sid} vào Redis với TTL bằng thời hạn tối đa của access token (15 phút). Gateway chỉ tốn một lệnh EXISTS mỗi request; deny list luôn nhỏ vì tự hết hạn.Put the sid in the access token's claims. On revocation, write revoked:{sid} to Redis with a TTL equal to the maximum access token lifetime (15 minutes). The gateway pays one EXISTS per request, and the deny list stays small because entries expire.
Đếm rồi chèn là check-then-act: hai request cùng thấy 1 phiên và cùng chèn. Khoá dòng app_user bằng SELECT … FOR UPDATE trước khi đếm, hoặc làm cả bước đếm, đẩy ra và chèn trong một Lua script nếu phiên nằm trong Redis.Count-then-insert is check-then-act: two requests both see one session and both insert. Lock the app_user row with SELECT … FOR UPDATE before counting, or run count, evict and insert as one Lua script when sessions live in Redis.
Kẻ tấn công cài sẵn một session id cho nạn nhân rồi chờ nạn nhân đăng nhập. Luôn cấp sid mới tại thời điểm xác thực thành công và khi nâng quyền (bật MFA, đổi mật khẩu). Spring Security mặc định gọi changeSessionId(); ở Go phải tự sinh sid mới.An attacker plants a session id on the victim and waits for them to sign in. Always issue a new sid on successful authentication and on privilege changes (MFA enabled, password changed). Spring Security calls changeSessionId() by default; in Go you generate a new sid yourself.
Ở 5.000 rps, mỗi request một UPDATE sẽ làm nghẽn Postgres. Chỉ ghi khi giá trị cũ quá 5 phút (SET last_seen:{sid} NX EX 300 làm cổng chặn), hoặc gom vào Redis rồi flush theo lô mỗi phút.At 5,000 rps, one UPDATE per request chokes Postgres. Write only when the stored value is over 5 minutes old (SET last_seen:{sid} NX EX 300 as a gate), or buffer in Redis and flush in batches every minute.
Idle timeout (15–30 phút với ứng dụng nhạy cảm) đóng phiên không dùng; absolute timeout (8 giờ đến 30 ngày) đóng phiên dù vẫn hoạt động, để token bị lộ không sống mãi. Kiểm tra cả hai ở bước refresh; với session trong Redis thì idle chính là TTL được gia hạn mỗi request. Một decorator bọc handler gia hạn TTL đó mà không đụng code nghiệp vụ.An idle timeout (15–30 minutes for sensitive apps) closes unused sessions; an absolute timeout (8 hours to 30 days) closes sessions even while active, so a leaked token cannot live forever. Check both at refresh; with Redis sessions, idle is simply the TTL extended on each request. A handler decorator can extend that TTL without touching business code.
Thu hồi mọi phiên của user trừ phiên hiện tại, đưa từng sid vào deny list. Khi đổi mật khẩu thì tăng app_user.token_version; token mang version cũ bị từ chối ở bước refresh mà không cần liệt kê từng phiên.Revoke every session of the user except the current one and put each sid on the deny list. On password change, bump app_user.token_version; tokens with an older version are refused at refresh without listing sessions.
Phát session.opened và session.revoked sau commit. Notifier gửi email thiết bị mới, WebSocket gateway ngắt kết nối của sid bị thu hồi, audit ghi lịch sử. Session Service không phải biết các bên này tồn tại.Publish session.opened and session.revoked after commit. The notifier emails about new devices, the WebSocket gateway drops connections for the revoked sid, audit records history. The session service does not need to know any of them exist.
| BảngTable | Cột chínhKey columns | Ghi chúNotes |
|---|---|---|
user_session | id (sid), user_id, device_name, user_agent, ip, geo, created_at, last_seen_at, expires_at, revoked_at, revoke_reason | index(user_id) WHERE revoked_at IS NULL; reason = USER · LIMIT · IDLE · LOGOUT_ALL · PASSWORD_CHANGEDindex(user_id) WHERE revoked_at IS NULL; reason = USER · LIMIT · IDLE · LOGOUT_ALL · PASSWORD_CHANGED |
refresh_token | id, session_id, token_hash, status, expires_at | Thu hồi phiên thì thu hồi mọi refresh token có session_id đóRevoking a session revokes every refresh token with that session_id |
Redis revoked:{sid} | value = reason, EX 900 | Deny list cho access token còn hạn; TTL = thời hạn tối đa của access tokenDeny list for unexpired access tokens; TTL = maximum access token lifetime |
Redis sess:{sid} (khi dùng session phía server) | HASH user_id, roles, created_at · EXPIRE 1800 gia hạn mỗi request | Spring Session Redis lưu theo dạng này; TTL chính là idle timeoutSpring Session Redis stores this shape; the TTL is the idle timeout |
app_user | id, plan, max_sessions, token_version | Khoá dòng này khi mở phiên; tăng token_version khi đổi mật khẩuLock this row when opening a session; bump token_version on password change |
Mở phiên mới có giới hạn số phiên: khoá user, đẩy phiên ít dùng nhất, chèn phiên mới. Deny list và sự kiện chỉ chạy sau khi commit.Opening a session with a limit: lock the user, evict the least recently used session, insert the new one. The deny list and events run only after commit.
@Service
@RequiredArgsConstructor
public class SessionService {
private final UserRepository users;
private final UserSessionRepository sessions;
private final ApplicationEventPublisher events;
@Transactional
public UserSession open(long userId, DeviceInfo device) {
AppUser user = users.lockById(userId); // SELECT … FOR UPDATE
List<UserSession> active = sessions.findActiveByLastSeenAsc(userId);
int excess = active.size() - user.getMaxSessions() + 1;
for (int i = 0; i < excess; i++) {
revoke(active.get(i), RevokeReason.LIMIT);
}
UserSession s = sessions.save(UserSession.open(userId, device));
events.publishEvent(new SessionOpened(s.getId(), userId, device));
return s;
}
@Transactional
public void revokeOthers(long userId, String currentSid) {
sessions.findActiveByLastSeenAsc(userId).stream()
.filter(s -> !s.getId().equals(currentSid))
.forEach(s -> revoke(s, RevokeReason.LOGOUT_ALL));
}
private void revoke(UserSession s, RevokeReason reason) {
s.revoke(reason, Instant.now()); // + its refresh tokens
events.publishEvent(new SessionRevoked(s.getId(), s.getUserId(), reason));
}
}
@Component
@RequiredArgsConstructor
class DenyListOnSessionRevoked {
private final StringRedisTemplate redis;
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
void on(SessionRevoked e) {
redis.opsForValue().set("revoked:" + e.sid(), e.reason().name(), Duration.ofMinutes(15));
}
}type SessionService struct {
tx TxRunner
redis *redis.Client
events Publisher
}
func (s *SessionService) Open(ctx context.Context, userID int64, d Device) (Session, error) {
var opened Session
var evicted []Session
err := s.tx.Do(ctx, func(tx Tx) error {
user, err := tx.LockUser(ctx, userID) // SELECT … FOR UPDATE
if err != nil {
return err
}
active, err := tx.ActiveSessionsByLastSeen(ctx, userID)
if err != nil {
return err
}
for i := 0; i < len(active)-user.MaxSessions+1; i++ {
if err := tx.RevokeSession(ctx, active[i].ID, ReasonLimit); err != nil {
return err
}
evicted = append(evicted, active[i])
}
opened, err = tx.InsertSession(ctx, NewSession(userID, d))
return err
})
if err != nil {
return Session{}, err
}
for _, e := range evicted { // only after commit
s.redis.Set(ctx, "revoked:"+e.ID, string(ReasonLimit), 15*time.Minute)
s.events.Publish(ctx, SessionRevoked{SID: e.ID, UserID: userID, Reason: ReasonLimit})
}
s.events.Publish(ctx, SessionOpened{SID: opened.ID, UserID: userID, Device: d})
return opened, nil
}
// Gateway middleware: one EXISTS per request.
func RequireLiveSession(rdb *redis.Client, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sid := ClaimsFrom(r.Context()).SID
if n, err := rdb.Exists(r.Context(), "revoked:"+sid).Result(); err != nil || n > 0 {
http.Error(w, "session_revoked", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}| Session trong Redis (Spring Session)Sessions in Redis (Spring Session) | JWT ngắn hạn + deny list theo sidShort-lived JWT + deny list by sid | |
|---|---|---|
| Thu hồiRevocation | Xoá khoá là xong, có hiệu lực ngayDelete the key and it takes effect at once | Cần deny list; không có thì phải chờ token hết hạnNeeds a deny list; without one you wait for expiry |
| Mỗi requestPer request | Một lần đọc Redis để lấy toàn bộ phiênOne Redis read to load the whole session | Kiểm chữ ký cục bộ + một EXISTS nhỏA local signature check plus one small EXISTS |
| Nhiều serviceMany services | Mọi service phải truy cập cùng RedisEvery service needs the same Redis | Service nào có JWKS là kiểm được, kể cả ở region khácAny service with the JWKS can verify, even in another region |
| Chọn khiPick it when | Ứng dụng web một khối, cần idle timeout chính xácA monolithic web app that needs a precise idle timeout | Nhiều microservice, mobile, API công khaiMany microservices, mobile clients, public APIs |
Mỗi pattern mở bài học tương ứng trong menu Pattern.Each one opens its lesson in the Patterns menu.
Token mang sid. Khi thu hồi, ghi revoked:{sid} vào Redis với TTL bằng thời hạn tối đa của access token; gateway kiểm tra khoá này sau khi kiểm chữ ký. Refresh token của phiên bị thu hồi trong DB nên phiên không gia hạn được.The token carries a sid. On revocation write revoked:{sid} to Redis with a TTL equal to the maximum access token lifetime; the gateway checks it after the signature. The session's refresh tokens are revoked in the DB, so it cannot be extended.
Cả hai request đếm thấy cùng số phiên rồi cùng chèn, kết quả là 3 phiên. Khoá dòng app_user trước khi đếm để hai request chạy tuần tự, hoặc dùng một Lua script trong Redis để đếm, đẩy ra và chèn nguyên tử.Both requests count the same number of sessions and both insert, leaving 3. Lock the app_user row before counting so they run one after the other, or use one Redis Lua script to count, evict and insert atomically.
Kẻ tấn công đưa cho nạn nhân một session id đã biết trước, nạn nhân đăng nhập, kẻ tấn công dùng lại id đó. Chặn bằng cách luôn cấp session id mới ngay sau khi xác thực thành công và khi đổi quyền.The attacker gives the victim a session id they already know, the victim signs in, and the attacker reuses that id. Prevent it by always issuing a new session id right after successful authentication and on privilege changes.
Idle đóng phiên sau một khoảng không hoạt động; absolute đóng phiên sau một thời gian cố định kể từ lúc đăng nhập. Chỉ có idle thì một token bị đánh cắp được dùng liên tục sẽ sống mãi, nên cần cả hai. OWASP gợi ý idle 15–30 phút với ứng dụng rủi ro cao.Idle closes a session after a period of inactivity; absolute closes it a fixed time after sign-in. With only idle, a stolen token used continuously lives forever, so you need both. OWASP suggests 15–30 minutes idle for high-risk apps.
Phím 1–4 để chọn đáp án, Enter để kiểm tra.Press 1–4 to choose an answer, Enter to check.
Ôn tập ngắt quãng (spaced repetition): thẻ được lặp lại sau 1 → 3 → 7 → 14 → 30 ngày để củng cố trí nhớ dài hạn.Spaced repetition: cards are repeated after 1 → 3 → 7 → 14 → 30 days to build long-term memory.