feat(api): 会话记录设备与最近活动,账户页可查看并退出任一设备,删除 passkey 或更换邮箱时退出其它设备,staff 会话空闲 30 分钟失效,吊销会话校验所属用户
This commit is contained in:
40 files changed
+2139
-179
No files matched your search
+55
-1
@@ -9,6 +9,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -28,6 +29,17 @@ const (
|
||||
sessionCookieName = "felis_session"
|
||||
// sessionTTL bounds a local session. Staff re-authenticate after it.
|
||||
sessionTTL = 12 * time.Hour
|
||||
// staffSessionIdle ends a staff session that has authenticated no request for
|
||||
// this long; a player session has only sessionTTL. Any authenticated request
|
||||
// counts, a panel tab's background refresh included, so what this ends is a
|
||||
// session left behind in a closed tab or on a machine that went to sleep.
|
||||
staffSessionIdle = 30 * time.Minute
|
||||
// sessionTouchEvery is how stale a session's last_seen_at may grow before a
|
||||
// request advances it: an active session costs one write a minute, not one per
|
||||
// request, and the idle limit is honored to within this.
|
||||
sessionTouchEvery = time.Minute
|
||||
// maxSessionUserAgent caps the User-Agent a session keeps to name its device.
|
||||
maxSessionUserAgent = 256
|
||||
)
|
||||
|
||||
// LocalAuthEnabledKey is the platform_settings key that gates whether
|
||||
@@ -54,6 +66,41 @@ func hashCookie(value string) string {
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// startSession mints a session for userID and sets its cookie. Every sign-in door
|
||||
// ends here, so every session records the device it was minted for.
|
||||
func (a *API) startSession(w http.ResponseWriter, r *http.Request, userID string) error {
|
||||
token, err := newSessionToken()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
expires := a.now().Add(sessionTTL)
|
||||
ip := ""
|
||||
if addr := a.clientIP(r); addr.IsValid() {
|
||||
ip = addr.String()
|
||||
}
|
||||
if err := a.Repo.CreateSession(r.Context(), NewSession{
|
||||
TokenHash: hashCookie(token),
|
||||
UserID: userID,
|
||||
ExpiresAt: expires,
|
||||
UserAgent: truncateUTF8(r.UserAgent(), maxSessionUserAgent),
|
||||
ClientIP: ip,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
setSessionCookie(w, token, expires)
|
||||
return nil
|
||||
}
|
||||
|
||||
// currentSessionHash is the storage key of the session cookie r carries, or ""
|
||||
// when it carries none.
|
||||
func currentSessionHash(r *http.Request) string {
|
||||
c, err := r.Cookie(sessionCookieName)
|
||||
if err != nil || c.Value == "" {
|
||||
return ""
|
||||
}
|
||||
return hashCookie(c.Value)
|
||||
}
|
||||
|
||||
// setSessionCookie writes the session cookie: HttpOnly + Secure + SameSite=Lax,
|
||||
// host-only (no Domain), rooted at "/". Secure means the console must be served
|
||||
// over HTTPS — already a hard requirement, since WebAuthn and Zero Trust both
|
||||
@@ -158,13 +205,20 @@ func (s SessionAuth) Authenticate(r *http.Request) (*Principal, error) {
|
||||
return nil, fmt.Errorf("local auth disabled")
|
||||
}
|
||||
|
||||
u, err := s.Repo.SessionUser(ctx, hashCookie(cookie.Value), s.now())
|
||||
hash, now := hashCookie(cookie.Value), s.now()
|
||||
u, err := s.Repo.SessionUser(ctx, hash, now)
|
||||
switch {
|
||||
case errors.Is(err, ErrNotFound):
|
||||
return nil, fmt.Errorf("invalid session: %w", err)
|
||||
case err != nil:
|
||||
return nil, fmt.Errorf("%w: %v", errAuthBackend, err)
|
||||
}
|
||||
if now.Sub(u.LastSeenAt) >= sessionTouchEvery {
|
||||
// A failed touch costs at most an early idle sign-out, so the request goes on.
|
||||
if err := s.Repo.TouchSession(ctx, hash, now); err != nil {
|
||||
log.Printf("api: record session activity (request_id=%s): %v", requestIDFromContext(ctx), err)
|
||||
}
|
||||
}
|
||||
return &Principal{
|
||||
UserID: u.ID,
|
||||
Username: u.Username,
|
||||
|
||||
Reference in new issue
Block a user