feat(auth): server-side sessions and current password for account changes (FLAW-30, FLAW-31)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m52s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m52s
- Every login is a session (device, IP, last use); the token's jti is its ID. Sessions end on logout, per device from the profile, or after 30 idle days. - Tokens older than a day are renewed via the X-Session-Token header. - Changing password or login e-mail needs the current password; a new password logs out all other devices and mails the old address. - Demo reset keeps the demo visitors' sessions. - revoked_tokens is replaced by sessions (migration 4); everyone is logged out once after the upgrade. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b2ecda9896
commit
1af3a6e90a
25 files changed
+705
-75
No files matched your search
@@ -145,7 +145,7 @@ func (s *Server) handleFirstBootSetup(w http.ResponseWriter, r *http.Request) {
|
||||
_ = s.store.SeedStarterContent(user)
|
||||
}
|
||||
|
||||
token, err := auth.GenerateToken(s.cfg.JWTSecret, user.ID, user.Email, user.DisplayName, user.TokenVersion)
|
||||
token, err := s.startSession(r, user)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Token konnte nicht erstellt werden")
|
||||
return
|
||||
@@ -229,7 +229,7 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
token, err := auth.GenerateToken(s.cfg.JWTSecret, user.ID, user.Email, user.DisplayName, user.TokenVersion)
|
||||
token, err := s.startSession(r, user)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Token konnte nicht erstellt werden")
|
||||
return
|
||||
@@ -297,7 +297,7 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
token, err := auth.GenerateToken(s.cfg.JWTSecret, user.ID, user.Email, user.DisplayName, user.TokenVersion)
|
||||
token, err := s.startSession(r, user)
|
||||
if err != nil {
|
||||
slog.Error("Login-Token konnte nicht erstellt werden", "login", identifier, "user_id", user.ID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "Token konnte nicht erstellt werden")
|
||||
@@ -393,21 +393,11 @@ func importUpload(w http.ResponseWriter, r *http.Request) ([]byte, string, error
|
||||
|
||||
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if strings.HasPrefix(authHeader, "Bearer ") {
|
||||
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
if claims, err := auth.ValidateToken(s.cfg.JWTSecret, tokenStr); err == nil && claims != nil {
|
||||
var exp time.Time
|
||||
if claims.ExpiresAt != nil {
|
||||
exp = claims.ExpiresAt.Time
|
||||
} else {
|
||||
exp = time.Now().Add(30 * 24 * time.Hour)
|
||||
}
|
||||
// The session must really end; the client is told if it did not
|
||||
if err := s.store.RevokeToken(tokenStr, userID, exp); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Abmelden fehlgeschlagen")
|
||||
return
|
||||
}
|
||||
// The session must really end; the client is told if it did not
|
||||
if sid := sessionIDFrom(r); sid != "" {
|
||||
if err := s.store.RevokeSession(userID, sid); err != nil && !errors.Is(err, storage.ErrSessionNotFound) {
|
||||
writeError(w, http.StatusInternalServerError, "Abmelden fehlgeschlagen")
|
||||
return
|
||||
}
|
||||
}
|
||||
// Streams are not tied to one session; other devices reconnect with a new ticket on their own
|
||||
@@ -437,11 +427,23 @@ func (s *Server) handleUpdateUserSettings(w http.ResponseWriter, r *http.Request
|
||||
DiscordWebhookURL *string `json:"discordWebhookUrl"`
|
||||
CustomWebhookURL *string `json:"customWebhookUrl"`
|
||||
NotifyBeforeMinutes *int `json:"notifyBeforeMinutes"`
|
||||
// Needed to change password or e-mail address (FLAW-30)
|
||||
CurrentPassword string `json:"currentPassword"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||
return
|
||||
}
|
||||
before, err := s.store.GetUserByID(userID)
|
||||
if err != nil || before == nil {
|
||||
writeError(w, http.StatusNotFound, "Benutzer nicht gefunden")
|
||||
return
|
||||
}
|
||||
changesPassword := req.Password != nil && strings.TrimSpace(*req.Password) != ""
|
||||
changesEmail := req.Email != nil && !strings.EqualFold(strings.TrimSpace(*req.Email), before.Email)
|
||||
if (changesPassword || changesEmail) && !s.checkCurrentPassword(w, r, before, req.CurrentPassword) {
|
||||
return
|
||||
}
|
||||
// Alarm URLs are checked before they are stored (SSRF protection, see netguard.go)
|
||||
allowPrivate := s.allowPrivateWebhooks()
|
||||
if req.DiscordWebhookURL != nil {
|
||||
@@ -470,7 +472,18 @@ func (s *Server) handleUpdateUserSettings(w http.ResponseWriter, r *http.Request
|
||||
writeError(w, http.StatusInternalServerError, "Passwort konnte nicht gespeichert werden")
|
||||
return
|
||||
}
|
||||
// Every other device is logged out; this one keeps its session with a fresh token
|
||||
sid := sessionIDFrom(r)
|
||||
if err := s.store.RevokeOtherSessions(userID, sid); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Andere Sitzungen konnten nicht beendet werden")
|
||||
return
|
||||
}
|
||||
s.wsHub.DisconnectUser(userID)
|
||||
if fresh, err := s.store.GetUserByID(userID); err == nil {
|
||||
current := &auth.Claims{}
|
||||
current.ID = sid
|
||||
s.renewSessionToken(w, fresh, current, true)
|
||||
}
|
||||
}
|
||||
u, err := s.store.UpdateUserFull(
|
||||
userID,
|
||||
@@ -489,9 +502,51 @@ func (s *Server) handleUpdateUserSettings(w http.ResponseWriter, r *http.Request
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if changesPassword || changesEmail {
|
||||
s.notifyAccountChange(before, changesPassword, changesEmail, u.Email)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, u)
|
||||
}
|
||||
|
||||
// checkCurrentPassword verifies the current password for sensitive changes; wrong attempts count
|
||||
// towards the login protection, so this cannot be used to guess the password.
|
||||
func (s *Server) checkCurrentPassword(w http.ResponseWriter, r *http.Request, user *domain.User, password string) bool {
|
||||
ip := clientIPFrom(r)
|
||||
if wait := s.loginGuard.lockedFor(user.ID, ip); wait > 0 {
|
||||
writeLoginLocked(w, wait)
|
||||
return false
|
||||
}
|
||||
// A forgotten field is no guess and does not count as a failure
|
||||
if password == "" || !auth.CheckPassword(password, user.PasswordHash) {
|
||||
if password != "" {
|
||||
s.loginGuard.recordFailure(user.ID, ip)
|
||||
}
|
||||
writeError(w, http.StatusForbidden, "Bitte gib zur Bestätigung dein aktuelles Passwort richtig ein")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// notifyAccountChange tells the previous address about a new password or e-mail address, so a
|
||||
// takeover does not go unnoticed. Only when mail is set up; errors are logged, never shown.
|
||||
func (s *Server) notifyAccountChange(before *domain.User, password, email bool, newEmail string) {
|
||||
if _, err := s.smtpSettings(); err != nil || before.Email == "" {
|
||||
return
|
||||
}
|
||||
var what []string
|
||||
if password {
|
||||
what = append(what, "dein Passwort wurde geändert")
|
||||
}
|
||||
if email {
|
||||
what = append(what, "die E-Mail-Adresse deines Kontos wurde auf "+newEmail+" geändert")
|
||||
}
|
||||
body := fmt.Sprintf("Hallo %s,\n\nin MindSift %s.\n\nWarst du das nicht, setze bitte sofort dein Passwort zurück und beende alle Sitzungen in den Kontoeinstellungen, oder wende dich an die Administration.\n",
|
||||
before.DisplayName, strings.Join(what, " und "))
|
||||
go func() {
|
||||
_ = s.sendEmailNotification(before.Email, "MindSift: Änderung an deinem Konto", body)
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *Server) handleTestUserAlarm(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
user, err := s.store.GetUserByID(userID)
|
||||
|
||||
@@ -19,7 +19,7 @@ func (s *Server) authenticateUserFromReq(r *http.Request) string {
|
||||
return ""
|
||||
}
|
||||
u, err := s.store.GetUserByID(tk.userID)
|
||||
if err != nil || u == nil || !u.IsActive || u.TokenVersion != tk.tokenVersion {
|
||||
if err != nil || u == nil || !u.IsActive || u.TokenVersion != tk.tokenVersion || !s.store.SessionActive(tk.sessionID, u.ID) {
|
||||
return ""
|
||||
}
|
||||
return u.ID
|
||||
|
||||
@@ -52,7 +52,7 @@ func newTestUser(t *testing.T, store *storage.Store, email string) (*domain.User
|
||||
if err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
tok, err := auth.GenerateToken(testJWTSecret, u.ID, u.Email, u.DisplayName, u.TokenVersion)
|
||||
tok, err := auth.GenerateToken(testJWTSecret, u.ID, u.Email, u.DisplayName, u.TokenVersion, testSession(t, store, u.ID))
|
||||
if err != nil {
|
||||
t.Fatalf("token: %v", err)
|
||||
}
|
||||
|
||||
+14
-2
@@ -97,7 +97,7 @@ func (s *Server) Router() http.Handler {
|
||||
AllowedOrigins: allowedOrigins,
|
||||
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
|
||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-API-Key"},
|
||||
ExposedHeaders: []string{"Link"},
|
||||
ExposedHeaders: []string{"Link", "X-Session-Token"},
|
||||
AllowCredentials: false,
|
||||
MaxAge: 300,
|
||||
}))
|
||||
@@ -125,6 +125,8 @@ func (s *Server) Router() http.Handler {
|
||||
r.Get("/api/v1/auth/me", s.handleMe)
|
||||
r.Post("/api/v1/events/rooms", s.handleSSERooms)
|
||||
r.Post("/api/v1/auth/stream-ticket", s.handleStreamTicket)
|
||||
r.Get("/api/v1/auth/sessions", s.handleListSessions)
|
||||
r.Delete("/api/v1/auth/sessions/{id}", s.handleEndSession)
|
||||
r.Post("/api/v1/auth/logout", s.handleLogout)
|
||||
r.Post("/api/v1/auth/revoke-all", s.handleRevokeAllSessions)
|
||||
r.Patch("/api/v1/auth/settings", s.handleUpdateUserSettings)
|
||||
@@ -417,7 +419,7 @@ func (s *Server) authenticateToken(token string) (user *domain.User, keyScopes [
|
||||
return u, scopes
|
||||
}
|
||||
claims, err := auth.ValidateToken(s.cfg.JWTSecret, token)
|
||||
if err != nil || claims == nil || s.store.IsTokenRevoked(token) {
|
||||
if err != nil || claims == nil {
|
||||
return nil, nil
|
||||
}
|
||||
u, err := s.store.GetUserByID(claims.UserID)
|
||||
@@ -427,6 +429,10 @@ func (s *Server) authenticateToken(token string) (user *domain.User, keyScopes [
|
||||
if claims.TokenVersion != 0 && claims.TokenVersion != u.TokenVersion {
|
||||
return nil, nil
|
||||
}
|
||||
// Logged out, ended from the session list or idle for 30 days
|
||||
if !s.store.SessionActive(claims.ID, u.ID) {
|
||||
return nil, nil
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
@@ -478,6 +484,12 @@ func (s *Server) authMiddleware(next http.Handler) http.Handler {
|
||||
return
|
||||
}
|
||||
ctx := auth.WithUserID(r.Context(), user.ID)
|
||||
if keyScopes == nil {
|
||||
if claims, err := auth.ValidateToken(s.cfg.JWTSecret, token); err == nil {
|
||||
ctx = withSession(ctx, claims.ID)
|
||||
s.renewSessionToken(w, user, claims, false)
|
||||
}
|
||||
}
|
||||
if keyScopes != nil {
|
||||
if !apiKeyAllowed(r) {
|
||||
writeError(w, http.StatusForbidden, "API-Keys dürfen nur Daten einliefern (POST /api/v1/inbox oder /api/v1/webhooks/ingest)")
|
||||
|
||||
@@ -47,7 +47,7 @@ func setupTestServer(t *testing.T) (*Server, *storage.Store, string) {
|
||||
hub := ws.NewHub()
|
||||
srv := NewServer(cfg, store, hub, nil)
|
||||
|
||||
token, err := auth.GenerateToken(cfg.JWTSecret, user.ID, user.Email, user.DisplayName, user.TokenVersion)
|
||||
token, err := auth.GenerateToken(cfg.JWTSecret, user.ID, user.Email, user.DisplayName, user.TokenVersion, testSession(t, store, user.ID))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate token: %v", err)
|
||||
}
|
||||
@@ -222,7 +222,7 @@ func TestQuickDumpToTriageAndDirectTargets(t *testing.T) {
|
||||
router := srv.Router()
|
||||
|
||||
// Verify stale token from non-existent user is rejected (401)
|
||||
staleToken, _ := auth.GenerateToken("test-jwt-secret-key", "usr_non_existent", "old@mindsift.local", "Old", 1)
|
||||
staleToken, _ := auth.GenerateToken("test-jwt-secret-key", "usr_non_existent", "old@mindsift.local", "Old", 1, "")
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+staleToken)
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -553,11 +553,11 @@ func TestSpaceMembersDirectInvitationAPI(t *testing.T) {
|
||||
t.Fatalf("failed to get normal user: %v", err)
|
||||
}
|
||||
|
||||
adminToken, err := auth.GenerateToken(cfg.JWTSecret, adminUser.ID, adminUser.Email, adminUser.DisplayName, adminUser.TokenVersion)
|
||||
adminToken, err := auth.GenerateToken(cfg.JWTSecret, adminUser.ID, adminUser.Email, adminUser.DisplayName, adminUser.TokenVersion, testSession(t, store, adminUser.ID))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate token: %v", err)
|
||||
}
|
||||
userToken, err := auth.GenerateToken(cfg.JWTSecret, normalUser.ID, normalUser.Email, normalUser.DisplayName, normalUser.TokenVersion)
|
||||
userToken, err := auth.GenerateToken(cfg.JWTSecret, normalUser.ID, normalUser.Email, normalUser.DisplayName, normalUser.TokenVersion, testSession(t, store, normalUser.ID))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate token: %v", err)
|
||||
}
|
||||
@@ -670,7 +670,7 @@ func TestWorkspaceTagsAPI(t *testing.T) {
|
||||
router := srv.Router()
|
||||
|
||||
user, _ := store.CreateUser("api_tag_user@example.com", "hash", "API Tag User", "member")
|
||||
token, _ := auth.GenerateToken(srv.cfg.JWTSecret, user.ID, user.Email, user.DisplayName, user.TokenVersion)
|
||||
token, _ := auth.GenerateToken(srv.cfg.JWTSecret, user.ID, user.Email, user.DisplayName, user.TokenVersion, testSession(t, store, user.ID))
|
||||
|
||||
// Create document with hashtags
|
||||
blocks := []domain.DocumentBlock{
|
||||
@@ -920,7 +920,7 @@ func TestDecoupledFolderSharingAPI(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create user B: %v", err)
|
||||
}
|
||||
tokenB, err := auth.GenerateToken(srv.cfg.JWTSecret, userB.ID, userB.Email, userB.DisplayName, userB.TokenVersion)
|
||||
tokenB, err := auth.GenerateToken(srv.cfg.JWTSecret, userB.ID, userB.Email, userB.DisplayName, userB.TokenVersion, testSession(t, store, userB.ID))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate token B: %v", err)
|
||||
}
|
||||
@@ -1238,10 +1238,10 @@ func TestDocumentSharingStrictlyReadOnly(t *testing.T) {
|
||||
router := srv.Router()
|
||||
|
||||
userA, _ := store.CreateUser("user_a_ro@example.com", "hash", "User A", "member")
|
||||
tokenA, _ := auth.GenerateToken(srv.cfg.JWTSecret, userA.ID, userA.Email, userA.DisplayName, userA.TokenVersion)
|
||||
tokenA, _ := auth.GenerateToken(srv.cfg.JWTSecret, userA.ID, userA.Email, userA.DisplayName, userA.TokenVersion, testSession(t, store, userA.ID))
|
||||
|
||||
userB, _ := store.CreateUser("user_b_ro@example.com", "hash", "User B", "member")
|
||||
tokenB, _ := auth.GenerateToken(srv.cfg.JWTSecret, userB.ID, userB.Email, userB.DisplayName, userB.TokenVersion)
|
||||
tokenB, _ := auth.GenerateToken(srv.cfg.JWTSecret, userB.ID, userB.Email, userB.DisplayName, userB.TokenVersion, testSession(t, store, userB.ID))
|
||||
|
||||
// Create document owned by User A
|
||||
blocks := []domain.DocumentBlock{
|
||||
@@ -1525,3 +1525,13 @@ func TestPasswordResetFlow(t *testing.T) {
|
||||
t.Errorf("token must work only once, got %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
// testSession starts a server-side session, as a login would, for tokens made directly in tests.
|
||||
func testSession(t *testing.T, store *storage.Store, userID string) string {
|
||||
t.Helper()
|
||||
sid, err := store.CreateSession(userID, "go-test", "192.0.2.1")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession: %v", err)
|
||||
}
|
||||
return sid
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/sandrodach/mindsift/internal/auth"
|
||||
"github.com/sandrodach/mindsift/internal/domain"
|
||||
"github.com/sandrodach/mindsift/internal/storage"
|
||||
)
|
||||
|
||||
// Sessions (FLAW-31): a login creates a server-side session (device, address, last use) whose ID is
|
||||
// the token's JWT ID. Requests renew the token once a day through the X-Session-Token response
|
||||
// header, so active devices stay logged in while idle sessions end after 30 days.
|
||||
|
||||
const tokenRenewAfter = 24 * time.Hour
|
||||
|
||||
type sessionIDKey struct{}
|
||||
|
||||
func sessionIDFrom(r *http.Request) string {
|
||||
id, _ := r.Context().Value(sessionIDKey{}).(string)
|
||||
return id
|
||||
}
|
||||
|
||||
// startSession creates the session for a fresh login and returns its token.
|
||||
func (s *Server) startSession(r *http.Request, user *domain.User) (string, error) {
|
||||
sid, err := s.store.CreateSession(user.ID, r.UserAgent(), clientIPFrom(r).ip)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return auth.GenerateToken(s.cfg.JWTSecret, user.ID, user.Email, user.DisplayName, user.TokenVersion, sid)
|
||||
}
|
||||
|
||||
// renewSessionToken sends a fresh token for the current session when the presented one is old (or
|
||||
// right away when force is set, e.g. after the own password changed).
|
||||
func (s *Server) renewSessionToken(w http.ResponseWriter, user *domain.User, claims *auth.Claims, force bool) {
|
||||
if claims == nil || claims.ID == "" {
|
||||
return
|
||||
}
|
||||
if !force && claims.IssuedAt != nil && time.Since(claims.IssuedAt.Time) < tokenRenewAfter {
|
||||
return
|
||||
}
|
||||
if token, err := auth.GenerateToken(s.cfg.JWTSecret, user.ID, user.Email, user.DisplayName, user.TokenVersion, claims.ID); err == nil {
|
||||
w.Header().Set("X-Session-Token", token)
|
||||
}
|
||||
}
|
||||
|
||||
func withSession(ctx context.Context, sessionID string) context.Context {
|
||||
return context.WithValue(ctx, sessionIDKey{}, sessionID)
|
||||
}
|
||||
|
||||
func (s *Server) handleListSessions(w http.ResponseWriter, r *http.Request) {
|
||||
list, err := s.store.ListSessions(auth.GetUserID(r.Context()), sessionIDFrom(r))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, list)
|
||||
}
|
||||
|
||||
// handleEndSession logs out one device.
|
||||
func (s *Server) handleEndSession(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
if err := s.store.RevokeSession(userID, chi.URLParam(r, "id")); err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
if errors.Is(err, storage.ErrSessionNotFound) {
|
||||
status = http.StatusNotFound
|
||||
}
|
||||
writeError(w, status, err.Error())
|
||||
return
|
||||
}
|
||||
// That device's streams end; the others reconnect with their still valid sessions
|
||||
s.wsHub.DisconnectUser(userID)
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"ended": true})
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/sandrodach/mindsift/internal/auth"
|
||||
"github.com/sandrodach/mindsift/internal/storage"
|
||||
)
|
||||
|
||||
func sessionCall(router http.Handler, method, path, token, body, ua string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(method, path, strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", ua)
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func sessionLogin(t *testing.T, router http.Handler, login, pw, ua string) string {
|
||||
t.Helper()
|
||||
rec := sessionCall(router, "POST", "/api/v1/auth/login", "", `{"login":"`+login+`","password":"`+pw+`"}`, ua)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("login: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var res struct{ Token string }
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &res)
|
||||
return res.Token
|
||||
}
|
||||
|
||||
func TestSessionsListEndAndLogout(t *testing.T) {
|
||||
srv, store, _ := setupTestServer(t)
|
||||
router := srv.Router()
|
||||
_, _ = store.CreateUser("sess@example.com", mustHash(t, "richtig123"), "Sess", "member")
|
||||
laptop := sessionLogin(t, router, "sess@example.com", "richtig123", "Mozilla/5.0 (X11; Linux x86_64) Firefox/130.0")
|
||||
phone := sessionLogin(t, router, "sess@example.com", "richtig123", "Mozilla/5.0 (iPhone) Safari/604.1")
|
||||
|
||||
rec := sessionCall(router, "GET", "/api/v1/auth/sessions", laptop, "", "")
|
||||
var list []storage.Session
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &list)
|
||||
if rec.Code != http.StatusOK || len(list) != 2 {
|
||||
t.Fatalf("want 2 sessions, got %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var other string
|
||||
for _, s := range list {
|
||||
if s.Current != strings.Contains(s.UserAgent, "Firefox") {
|
||||
t.Fatalf("current flag wrong: %+v", s)
|
||||
}
|
||||
if !s.Current {
|
||||
other = s.ID
|
||||
}
|
||||
}
|
||||
|
||||
// Ending the phone's session logs the phone out, the laptop stays
|
||||
if rec := sessionCall(router, "DELETE", "/api/v1/auth/sessions/"+other, laptop, "", ""); rec.Code != http.StatusOK {
|
||||
t.Fatalf("end session: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec := sessionCall(router, "GET", "/api/v1/auth/me", phone, "", ""); rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("ended session must be rejected, got %d", rec.Code)
|
||||
}
|
||||
if rec := sessionCall(router, "GET", "/api/v1/auth/me", laptop, "", ""); rec.Code != http.StatusOK {
|
||||
t.Fatalf("other session must stay, got %d", rec.Code)
|
||||
}
|
||||
// Other users' sessions cannot be ended
|
||||
_, strangerToken := newTestUser(t, store, "stranger@example.com")
|
||||
list = nil
|
||||
_ = json.Unmarshal(sessionCall(router, "GET", "/api/v1/auth/sessions", laptop, "", "").Body.Bytes(), &list)
|
||||
if rec := sessionCall(router, "DELETE", "/api/v1/auth/sessions/"+list[0].ID, strangerToken, "", ""); rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("foreign session: want 404, got %d", rec.Code)
|
||||
}
|
||||
|
||||
if rec := sessionCall(router, "POST", "/api/v1/auth/logout", laptop, "", ""); rec.Code != http.StatusOK {
|
||||
t.Fatalf("logout: %d", rec.Code)
|
||||
}
|
||||
if rec := sessionCall(router, "GET", "/api/v1/auth/me", laptop, "", ""); rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("token must be dead after logout, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionTokenRenewedWhenOld(t *testing.T) {
|
||||
srv, store, token := setupTestServer(t)
|
||||
router := srv.Router()
|
||||
if rec := sessionCall(router, "GET", "/api/v1/auth/me", token, "", ""); rec.Header().Get("X-Session-Token") != "" {
|
||||
t.Fatal("fresh token must not be renewed")
|
||||
}
|
||||
claims, err := auth.ValidateToken(srv.cfg.JWTSecret, token)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
claims.IssuedAt = jwt.NewNumericDate(time.Now().Add(-48 * time.Hour))
|
||||
old, _ := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(srv.cfg.JWTSecret))
|
||||
rec := sessionCall(router, "GET", "/api/v1/auth/me", old, "", "")
|
||||
renewed := rec.Header().Get("X-Session-Token")
|
||||
if rec.Code != http.StatusOK || renewed == "" {
|
||||
t.Fatalf("old token must be renewed, got %d %q", rec.Code, renewed)
|
||||
}
|
||||
fresh, err := auth.ValidateToken(srv.cfg.JWTSecret, renewed)
|
||||
if err != nil || fresh.ID != claims.ID {
|
||||
t.Fatalf("renewed token must keep the session: %v", err)
|
||||
}
|
||||
_ = store
|
||||
}
|
||||
|
||||
func TestPasswordChangeNeedsCurrentPassword(t *testing.T) {
|
||||
srv, store, _ := setupTestServer(t)
|
||||
router := srv.Router()
|
||||
_, _ = store.CreateUser("pw@example.com", mustHash(t, "richtig123"), "Pw", "member")
|
||||
me := sessionLogin(t, router, "pw@example.com", "richtig123", "laptop")
|
||||
other := sessionLogin(t, router, "pw@example.com", "richtig123", "phone")
|
||||
|
||||
for _, body := range []string{
|
||||
`{"password":"neuesPasswort1"}`,
|
||||
`{"password":"neuesPasswort1","currentPassword":"falsch"}`,
|
||||
`{"email":"neu@example.com"}`,
|
||||
} {
|
||||
if rec := sessionCall(router, "PATCH", "/api/v1/auth/settings", me, body, ""); rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("%s: want 403, got %d", body, rec.Code)
|
||||
}
|
||||
}
|
||||
// Harmless changes need no password
|
||||
if rec := sessionCall(router, "PATCH", "/api/v1/auth/settings", me, `{"displayName":"Neu"}`, ""); rec.Code != http.StatusOK {
|
||||
t.Fatalf("display name: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
rec := sessionCall(router, "PATCH", "/api/v1/auth/settings", me, `{"password":"neuesPasswort1","currentPassword":"richtig123"}`, "")
|
||||
renewed := rec.Header().Get("X-Session-Token")
|
||||
if rec.Code != http.StatusOK || renewed == "" {
|
||||
t.Fatalf("password change: want 200 with new token, got %d %q", rec.Code, renewed)
|
||||
}
|
||||
if rec := sessionCall(router, "GET", "/api/v1/auth/me", renewed, "", ""); rec.Code != http.StatusOK {
|
||||
t.Fatalf("this device must stay logged in, got %d", rec.Code)
|
||||
}
|
||||
if rec := sessionCall(router, "GET", "/api/v1/auth/me", other, "", ""); rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("other device must be logged out, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ const streamTicketTTL = 60 * time.Second
|
||||
|
||||
type streamTicket struct {
|
||||
userID string
|
||||
sessionID string
|
||||
tokenVersion int
|
||||
expires time.Time
|
||||
}
|
||||
@@ -31,7 +32,7 @@ type streamTickets struct {
|
||||
tickets map[string]streamTicket
|
||||
}
|
||||
|
||||
func (t *streamTickets) issue(userID string, tokenVersion int) (string, error) {
|
||||
func (t *streamTickets) issue(userID, sessionID string, tokenVersion int) (string, error) {
|
||||
buf := make([]byte, 24)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
@@ -48,7 +49,7 @@ func (t *streamTickets) issue(userID string, tokenVersion int) (string, error) {
|
||||
delete(t.tickets, k)
|
||||
}
|
||||
}
|
||||
t.tickets[id] = streamTicket{userID: userID, tokenVersion: tokenVersion, expires: now.Add(streamTicketTTL)}
|
||||
t.tickets[id] = streamTicket{userID: userID, sessionID: sessionID, tokenVersion: tokenVersion, expires: now.Add(streamTicketTTL)}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
@@ -70,7 +71,7 @@ func (s *Server) handleStreamTicket(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusUnauthorized, "Nicht autorisiert")
|
||||
return
|
||||
}
|
||||
ticket, err := s.streamTickets.issue(user.ID, user.TokenVersion)
|
||||
ticket, err := s.streamTickets.issue(user.ID, sessionIDFrom(r), user.TokenVersion)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Ticket konnte nicht erstellt werden")
|
||||
return
|
||||
|
||||
@@ -58,13 +58,16 @@ func CheckPassword(password, hash string) bool {
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func GenerateToken(secret, userID, email, displayName string, tokenVersion int) (string, error) {
|
||||
// TokenLifetime is the absolute lifetime of a session token; while the session is used the token is
|
||||
// renewed long before (see api.authMiddleware), idle sessions end after 30 days on the server side.
|
||||
const TokenLifetime = 30 * 24 * time.Hour
|
||||
|
||||
// GenerateToken signs a session token; sessionID (the JWT ID) names the server-side session row.
|
||||
func GenerateToken(secret, userID, email, displayName string, tokenVersion int, sessionID string) (string, error) {
|
||||
if tokenVersion <= 0 {
|
||||
tokenVersion = 1
|
||||
}
|
||||
b := make([]byte, 16)
|
||||
_, _ = rand.Read(b)
|
||||
jti := hex.EncodeToString(b)
|
||||
jti := sessionID
|
||||
|
||||
now := time.Now()
|
||||
claims := &Claims{
|
||||
@@ -74,7 +77,7 @@ func GenerateToken(secret, userID, email, displayName string, tokenVersion int)
|
||||
TokenVersion: tokenVersion,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ID: jti,
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(30 * 24 * time.Hour)),
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(TokenLifetime)),
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
Issuer: "mindsift",
|
||||
},
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
|
||||
// ResetAndSeedDemo drops all tables, recreates the schema, and seeds demo accounts and demo data.
|
||||
func (s *Store) ResetAndSeedDemo() error {
|
||||
// Logged-in demo visitors stay logged in: their sessions survive the reset (fixed account IDs)
|
||||
kept := s.demoSessions()
|
||||
if err := s.dropAllTables(); err != nil {
|
||||
return fmt.Errorf("reset demo: drop all tables: %w", err)
|
||||
}
|
||||
@@ -16,6 +18,29 @@ func (s *Store) ResetAndSeedDemo() error {
|
||||
if err := s.EnsureDemoAccounts(); err != nil {
|
||||
return fmt.Errorf("reset demo: ensure demo accounts: %w", err)
|
||||
}
|
||||
for _, k := range kept {
|
||||
_, _ = s.db.Exec(`INSERT OR IGNORE INTO sessions (id, user_id, created_at, last_used_at, user_agent, ip) VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
k.id, k.userID, k.created, k.lastUsed, k.agent, k.ip)
|
||||
}
|
||||
slog.Info("🧹 Demo-Modus: Datenbank für Testumgebung zurückgesetzt und neu befüllt")
|
||||
return nil
|
||||
}
|
||||
|
||||
type keptSession struct{ id, userID, created, lastUsed, agent, ip string }
|
||||
|
||||
func (s *Store) demoSessions() []keptSession {
|
||||
rows, err := s.db.Query(`SELECT id, user_id, created_at, last_used_at, user_agent, ip FROM sessions
|
||||
WHERE revoked_at = '' AND user_id IN (?, ?)`, DemoAdminID, DemoUserID)
|
||||
if err != nil {
|
||||
return nil // first start: no sessions table yet
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []keptSession
|
||||
for rows.Next() {
|
||||
var k keptSession
|
||||
if rows.Scan(&k.id, &k.userID, &k.created, &k.lastUsed, &k.agent, &k.ip) == nil {
|
||||
out = append(out, k)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -24,6 +24,7 @@ type migration struct {
|
||||
|
||||
var migrations = []migration{
|
||||
{3, "Basis-Schema v0.4.1", (*Store).migrateBaseline},
|
||||
{4, "Sitzungen", (*Store).migrateSessions},
|
||||
}
|
||||
|
||||
func (s *Store) migrate() error {
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Sessions (FLAW-31): every login is a row; the session token carries its ID. A session ends on
|
||||
// logout, when it is ended from the session list, when all sessions are ended, or after
|
||||
// SessionIdleTimeout without use. Tokens are renewed while the session is used (sliding login).
|
||||
|
||||
// SessionIdleTimeout ends sessions that were not used for this long.
|
||||
const SessionIdleTimeout = 30 * 24 * time.Hour
|
||||
|
||||
// sessionTouchEvery limits writes: last_used_at is updated at most this often.
|
||||
const sessionTouchEvery = 5 * time.Minute
|
||||
|
||||
var ErrSessionNotFound = errors.New("Sitzung nicht gefunden")
|
||||
|
||||
type Session struct {
|
||||
ID string `json:"id"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
LastUsedAt string `json:"lastUsedAt"`
|
||||
UserAgent string `json:"userAgent"`
|
||||
IP string `json:"ip"`
|
||||
Current bool `json:"current"`
|
||||
}
|
||||
|
||||
func (s *Store) migrateSessions() error {
|
||||
return s.execInTx(
|
||||
stmt(`CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
last_used_at TEXT NOT NULL,
|
||||
user_agent TEXT NOT NULL DEFAULT '',
|
||||
ip TEXT NOT NULL DEFAULT '',
|
||||
revoked_at TEXT NOT NULL DEFAULT ''
|
||||
)`),
|
||||
stmt(`CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id)`),
|
||||
// Replaced by sessions: a logout now ends the session instead of listing the token
|
||||
stmt(`DROP TABLE IF EXISTS revoked_tokens`),
|
||||
)
|
||||
}
|
||||
|
||||
func nowUTC() string { return time.Now().UTC().Format(time.RFC3339) }
|
||||
|
||||
// CreateSession starts a session for a login; old ended or idle sessions of the user are removed.
|
||||
func (s *Store) CreateSession(userID, userAgent, ip string) (string, error) {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
id := hex.EncodeToString(b)
|
||||
if len(userAgent) > 300 {
|
||||
userAgent = userAgent[:300]
|
||||
}
|
||||
idleCutoff := time.Now().Add(-SessionIdleTimeout).UTC().Format(time.RFC3339)
|
||||
return id, s.execInTx(
|
||||
stmt(`DELETE FROM sessions WHERE user_id = ? AND (revoked_at != '' OR last_used_at < ?)`, userID, idleCutoff),
|
||||
stmt(`INSERT INTO sessions (id, user_id, created_at, last_used_at, user_agent, ip) VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
id, userID, nowUTC(), nowUTC(), userAgent, ip),
|
||||
)
|
||||
}
|
||||
|
||||
// SessionActive reports whether the session belongs to the user, was not ended and was used recently.
|
||||
// It also records the use (at most every few minutes).
|
||||
func (s *Store) SessionActive(sessionID, userID string) bool {
|
||||
if sessionID == "" {
|
||||
return false
|
||||
}
|
||||
var lastUsed, revoked string
|
||||
err := s.db.QueryRow(`SELECT last_used_at, revoked_at FROM sessions WHERE id = ? AND user_id = ?`, sessionID, userID).
|
||||
Scan(&lastUsed, &revoked)
|
||||
if err != nil || revoked != "" {
|
||||
return false
|
||||
}
|
||||
last, err := time.Parse(time.RFC3339, lastUsed)
|
||||
if err != nil || time.Since(last) > SessionIdleTimeout {
|
||||
return false
|
||||
}
|
||||
if time.Since(last) > sessionTouchEvery {
|
||||
_, _ = s.db.Exec(`UPDATE sessions SET last_used_at = ? WHERE id = ?`, nowUTC(), sessionID)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ListSessions returns the user's active sessions, most recently used first.
|
||||
func (s *Store) ListSessions(userID, currentID string) ([]Session, error) {
|
||||
idleCutoff := time.Now().Add(-SessionIdleTimeout).UTC().Format(time.RFC3339)
|
||||
rows, err := s.db.Query(`SELECT id, created_at, last_used_at, user_agent, ip FROM sessions
|
||||
WHERE user_id = ? AND revoked_at = '' AND last_used_at >= ? ORDER BY last_used_at DESC`, userID, idleCutoff)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []Session{}
|
||||
for rows.Next() {
|
||||
var ss Session
|
||||
if err := rows.Scan(&ss.ID, &ss.CreatedAt, &ss.LastUsedAt, &ss.UserAgent, &ss.IP); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ss.Current = ss.ID == currentID
|
||||
out = append(out, ss)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// RevokeSession ends one session of the user.
|
||||
func (s *Store) RevokeSession(userID, sessionID string) error {
|
||||
res, err := s.db.Exec(`UPDATE sessions SET revoked_at = ? WHERE id = ? AND user_id = ? AND revoked_at = ''`, nowUTC(), sessionID, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrSessionNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RevokeOtherSessions ends every session of the user except keepID ("" ends all).
|
||||
func (s *Store) RevokeOtherSessions(userID, keepID string) error {
|
||||
_, err := s.db.Exec(`UPDATE sessions SET revoked_at = ? WHERE user_id = ? AND id != ? AND revoked_at = ''`, nowUTC(), userID, keepID)
|
||||
return err
|
||||
}
|
||||
@@ -303,7 +303,7 @@ func (s *Store) DeleteUser(userID string) error {
|
||||
return err
|
||||
}
|
||||
if err := s.execInTx(
|
||||
stmt(`DELETE FROM revoked_tokens WHERE user_id = ?`, userID),
|
||||
stmt(`DELETE FROM sessions WHERE user_id = ?`, userID),
|
||||
stmt(`DELETE FROM task_assignees WHERE user_id = ?`, userID),
|
||||
stmt(`DELETE FROM password_reset_tokens WHERE user_id = ?`, userID),
|
||||
stmt(`DELETE FROM users WHERE id = ?`, userID),
|
||||
@@ -315,33 +315,12 @@ func (s *Store) DeleteUser(userID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// RevokeToken adds an individual JWT to the blacklist until its expiration date
|
||||
func (s *Store) RevokeToken(tokenStr, userID string, expiresAt time.Time) error {
|
||||
hash := auth.HashAPIKey(tokenStr)
|
||||
_, err := s.db.Exec(
|
||||
`INSERT OR REPLACE INTO revoked_tokens (token_hash, user_id, expires_at, revoked_at) VALUES (?, ?, ?, ?)`,
|
||||
hash, userID, expiresAt.UTC().Format(time.RFC3339), time.Now().UTC().Format(time.RFC3339),
|
||||
)
|
||||
// Idempotently prune old expired tokens
|
||||
_, _ = s.db.Exec(`DELETE FROM revoked_tokens WHERE expires_at < ?`, time.Now().UTC().Format(time.RFC3339))
|
||||
return err
|
||||
}
|
||||
|
||||
// IsTokenRevoked checks if a specific JWT token has been explicitly blacklisted
|
||||
func (s *Store) IsTokenRevoked(tokenStr string) bool {
|
||||
hash := auth.HashAPIKey(tokenStr)
|
||||
var count int
|
||||
err := s.db.QueryRow(
|
||||
`SELECT COUNT(1) FROM revoked_tokens WHERE token_hash = ? AND expires_at >= ?`,
|
||||
hash, time.Now().UTC().Format(time.RFC3339),
|
||||
).Scan(&count)
|
||||
return err == nil && count > 0
|
||||
}
|
||||
|
||||
// RevokeAllUserTokens increments the user's token_version, instantly invalidating all existing JWTs for that user
|
||||
func (s *Store) RevokeAllUserTokens(userID string) error {
|
||||
_, err := s.db.Exec(`UPDATE users SET token_version = token_version + 1 WHERE id = ?`, userID)
|
||||
return err
|
||||
return s.execInTx(
|
||||
stmt(`UPDATE users SET token_version = token_version + 1 WHERE id = ?`, userID),
|
||||
stmt(`UPDATE sessions SET revoked_at = ? WHERE user_id = ? AND revoked_at = ''`, nowUTC(), userID),
|
||||
)
|
||||
}
|
||||
|
||||
// --- USER INVITES ---
|
||||
|
||||
Reference in new issue
Block a user