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

- 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:
sandrodachandClaude Opus 5.5 committed 2026-10-29 19:25:22 +01:00
1 parent b2ecda9896
commit 1af3a6e90a
25 files changed
+705 -75

No files matched your search

+73 -18
View File
@@ -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)
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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)")
+18 -8
View File
@@ -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
}
+78
View File
@@ -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})
}
+144
View File
@@ -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)
}
}
+4 -3
View File
@@ -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