diff --git a/pkg/db/fixtures/sessions.yml b/pkg/db/fixtures/sessions.yml
new file mode 100644
index 000000000..0b96b1eb4
--- /dev/null
+++ b/pkg/db/fixtures/sessions.yml
@@ -0,0 +1,27 @@
+- id: "550e8400-e29b-41d4-a716-446655440001"
+ user_id: 1
+ token_hash: "ab7459bbf90562c0412ff2b43b55e2a8aa705e243f3959d2ad1ed3d3ee4e164e"
+ device_info: "Mozilla/5.0 Test Browser"
+ ip_address: "192.168.1.1"
+ is_long_session: false
+ last_active: 2099-01-01 00:00:00
+ created: 2024-01-01 00:00:00
+ # refresh token in plaintext: testtoken_session1
+- id: "550e8400-e29b-41d4-a716-446655440002"
+ user_id: 1
+ token_hash: "87256b0b9dcb743578dbc84af87f53541ab2b57fa3cc863db026bc301a0d1e4c"
+ device_info: "Mobile App"
+ ip_address: "10.0.0.1"
+ is_long_session: true
+ last_active: 2099-01-01 00:00:00
+ created: 2024-01-02 00:00:00
+ # refresh token in plaintext: testtoken_session2
+- id: "550e8400-e29b-41d4-a716-446655440003"
+ user_id: 2
+ token_hash: "2f5a3a993aea0aae984f4810cdc5ce4358ed808fd5b7fb5a6886178349f2e7ad"
+ device_info: "Other User Browser"
+ ip_address: "172.16.0.1"
+ is_long_session: false
+ last_active: 2099-01-01 00:00:00
+ created: 2024-01-03 00:00:00
+ # refresh token in plaintext: testtoken_session3
diff --git a/pkg/initialize/init.go b/pkg/initialize/init.go
index 0ab0812fb..9595e1705 100644
--- a/pkg/initialize/init.go
+++ b/pkg/initialize/init.go
@@ -126,6 +126,7 @@ func FullInit() {
models.RegisterOldExportCleanupCron()
models.RegisterAddTaskToFilterViewCron()
user.RegisterTokenCleanupCron()
+ models.RegisterSessionCleanupCron()
user.RegisterDeletionNotificationCron()
openid.CleanupSavedOpenIDProviders()
openid.RegisterEmptyOpenIDTeamCleanupCron()
diff --git a/pkg/models/error.go b/pkg/models/error.go
index 61834d23d..89179e440 100644
--- a/pkg/models/error.go
+++ b/pkg/models/error.go
@@ -2155,3 +2155,32 @@ type ErrOpenIDBadRequestWithDetails struct {
func (err *ErrOpenIDBadRequestWithDetails) Error() string {
return err.Message
}
+
+// ==============
+// Session Errors
+// ==============
+
+// ErrSessionNotFound represents an error where a session was not found
+type ErrSessionNotFound struct{}
+
+// IsErrSessionNotFound checks if an error is ErrSessionNotFound.
+func IsErrSessionNotFound(err error) bool {
+ _, ok := err.(*ErrSessionNotFound)
+ return ok
+}
+
+func (err *ErrSessionNotFound) Error() string {
+ return "Session not found"
+}
+
+// ErrCodeSessionNotFound holds the unique world-error code of this error
+const ErrCodeSessionNotFound = 16001
+
+// HTTPError holds the http error description
+func (err *ErrSessionNotFound) HTTPError() web.HTTPError {
+ return web.HTTPError{
+ HTTPCode: http.StatusNotFound,
+ Code: ErrCodeSessionNotFound,
+ Message: "The session does not exist.",
+ }
+}
diff --git a/pkg/models/sessions.go b/pkg/models/sessions.go
new file mode 100644
index 000000000..f63476648
--- /dev/null
+++ b/pkg/models/sessions.go
@@ -0,0 +1,237 @@
+// Vikunja is a to-do list application to facilitate your life.
+// Copyright 2018-present Vikunja and contributors. All rights reserved.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package models
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "time"
+
+ "code.vikunja.io/api/pkg/config"
+ "code.vikunja.io/api/pkg/cron"
+ "code.vikunja.io/api/pkg/db"
+ "code.vikunja.io/api/pkg/log"
+ "code.vikunja.io/api/pkg/utils"
+ "code.vikunja.io/api/pkg/web"
+
+ "github.com/google/uuid"
+ "xorm.io/builder"
+ "xorm.io/xorm"
+)
+
+// Session represents an active user session with a refresh token.
+type Session struct {
+ // The session UUID. Embedded in JWTs as the `sid` claim.
+ ID string `xorm:"varchar(36) not null unique pk" json:"id" param:"session"`
+ // The owning user.
+ UserID int64 `xorm:"bigint not null index" json:"-"`
+ // SHA-256 hash of the refresh token. Used for lookup on refresh.
+ TokenHash string `xorm:"varchar(64) not null unique index" json:"-"`
+ // The cleartext refresh token. Only populated on session creation, never stored.
+ RefreshToken string `xorm:"-" json:"refresh_token,omitempty"`
+ // User-Agent string from the login request.
+ DeviceInfo string `xorm:"text" json:"device_info"`
+ // IP address from the login request.
+ IPAddress string `xorm:"varchar(100)" json:"ip_address"`
+ // Whether this is a "remember me" session (controls max refresh lifetime).
+ IsLongSession bool `xorm:"not null default false" json:"-"`
+ // When this session was last refreshed.
+ LastActive time.Time `xorm:"not null" json:"last_active"`
+ // When this session was created (login time).
+ Created time.Time `xorm:"created not null" json:"created"`
+
+ web.Permissions `xorm:"-" json:"-"`
+ web.CRUDable `xorm:"-" json:"-"`
+}
+
+func (*Session) TableName() string {
+ return "sessions"
+}
+
+// HashSessionToken returns the hex-encoded SHA-256 hash of a token string.
+// No salt needed because refresh tokens are high-entropy random strings,
+// not human passwords — rainbow tables and dictionary attacks don't apply.
+func HashSessionToken(token string) string {
+ h := sha256.Sum256([]byte(token))
+ return hex.EncodeToString(h[:])
+}
+
+// generateHashedToken creates a cryptographically random token and returns both
+// the raw hex-encoded token (to give to the client) and its SHA-256 hash (to store).
+func generateHashedToken() (rawToken, hash string, err error) {
+ tokenBytes, err := utils.CryptoRandomBytes(128)
+ if err != nil {
+ return "", "", err
+ }
+ rawToken = hex.EncodeToString(tokenBytes)
+ return rawToken, HashSessionToken(rawToken), nil
+}
+
+// CreateSession creates a new session record and generates a refresh token.
+// Returns the session with RefreshToken populated (cleartext, shown only once).
+func CreateSession(s *xorm.Session, userID int64, deviceInfo, ipAddress string, isLongSession bool) (*Session, error) {
+ rawToken, hash, err := generateHashedToken()
+ if err != nil {
+ return nil, err
+ }
+
+ session := &Session{
+ ID: uuid.New().String(),
+ UserID: userID,
+ TokenHash: hash,
+ DeviceInfo: deviceInfo,
+ IPAddress: ipAddress,
+ IsLongSession: isLongSession,
+ LastActive: time.Now(),
+ }
+
+ _, err = s.Insert(session)
+ if err != nil {
+ return nil, err
+ }
+
+ session.RefreshToken = rawToken
+ return session, nil
+}
+
+// GetSessionByRefreshToken finds a session by the SHA-256 hash of the provided token.
+func GetSessionByRefreshToken(s *xorm.Session, token string) (*Session, error) {
+ hash := HashSessionToken(token)
+ session := &Session{}
+ has, err := s.Where("token_hash = ?", hash).Get(session)
+ if err != nil {
+ return nil, err
+ }
+ if !has {
+ return nil, &ErrSessionNotFound{}
+ }
+ return session, nil
+}
+
+// GetSessionByID finds a session by its UUID.
+func GetSessionByID(s *xorm.Session, id string) (*Session, error) {
+ session := &Session{}
+ has, err := s.Where("id = ?", id).Get(session)
+ if err != nil {
+ return nil, err
+ }
+ if !has {
+ return nil, &ErrSessionNotFound{}
+ }
+ return session, nil
+}
+
+// ReadAll returns all sessions for the authenticated user.
+func (sess *Session) ReadAll(s *xorm.Session, a web.Auth, _ string, page int, perPage int) (result interface{}, resultCount int, numberOfTotalItems int64, err error) {
+ // Link share tokens must not be able to list user sessions.
+ if _, is := a.(*LinkSharing); is {
+ return nil, 0, 0, ErrGenericForbidden{}
+ }
+
+ sessions := []*Session{}
+
+ var where builder.Cond = builder.Eq{"user_id": a.GetID()}
+
+ err = s.
+ Where(where).
+ OrderBy("last_active DESC").
+ Limit(getLimitFromPageIndex(page, perPage)).
+ Find(&sessions)
+ if err != nil {
+ return nil, 0, 0, err
+ }
+
+ totalCount, err := s.Where(where).Count(&Session{})
+ return sessions, len(sessions), totalCount, err
+}
+
+// Delete deletes a session by ID, scoped to the owning user.
+func (sess *Session) Delete(s *xorm.Session, a web.Auth) error {
+ _, err := s.Where("id = ? AND user_id = ?", sess.ID, a.GetID()).Delete(&Session{})
+ return err
+}
+
+// UpdateSessionLastActive updates the last_active timestamp of a session.
+func UpdateSessionLastActive(s *xorm.Session, sessionID string) error {
+ _, err := s.Where("id = ?", sessionID).
+ Cols("last_active").
+ Update(&Session{LastActive: time.Now()})
+ return err
+}
+
+// RotateRefreshToken atomically replaces the session's refresh token hash.
+// The WHERE clause includes the old hash so that concurrent refreshes with the
+// same token cannot both succeed — only the first UPDATE matches a row; the
+// second sees 0 affected rows and returns ErrSessionNotFound.
+func RotateRefreshToken(s *xorm.Session, session *Session) (newRawToken string, err error) {
+ newRawToken, newHash, err := generateHashedToken()
+ if err != nil {
+ return "", err
+ }
+
+ affected, err := s.Where("id = ? AND token_hash = ?", session.ID, session.TokenHash).
+ Cols("token_hash").
+ Update(&Session{TokenHash: newHash})
+ if err != nil {
+ return "", err
+ }
+ if affected == 0 {
+ // Another request already rotated this token — reject the replay.
+ return "", &ErrSessionNotFound{}
+ }
+ return newRawToken, nil
+}
+
+// DeleteAllUserSessions removes all sessions for a user (e.g., on password change).
+func DeleteAllUserSessions(s *xorm.Session, userID int64) error {
+ _, err := s.Where("user_id = ?", userID).Delete(&Session{})
+ return err
+}
+
+// RegisterSessionCleanupCron registers a cron to delete sessions whose refresh
+// tokens have expired. Uses is_long_session to pick the right cutoff so short
+// sessions don't linger for the full long TTL. Runs hourly.
+func RegisterSessionCleanupCron() {
+ const logPrefix = "[Session Cleanup Cron] "
+
+ err := cron.Schedule("0 * * * *", func() {
+ s := db.NewSession()
+ defer s.Close()
+
+ now := time.Now()
+ shortMaxAge := time.Duration(config.ServiceJWTTTL.GetInt64()) * time.Second
+ longMaxAge := time.Duration(config.ServiceJWTTTLLong.GetInt64()) * time.Second
+
+ // Delete short sessions older than ServiceJWTTTL
+ // and long sessions older than ServiceJWTTTLLong
+ deleted, err := s.
+ Where("(is_long_session = ? AND last_active < ?) OR (is_long_session = ? AND last_active < ?)",
+ false, now.Add(-shortMaxAge),
+ true, now.Add(-longMaxAge)).
+ Delete(&Session{})
+ if err != nil {
+ log.Errorf(logPrefix+"Error removing stale sessions: %s", err)
+ return
+ }
+ if deleted > 0 {
+ log.Debugf(logPrefix+"Deleted %d stale sessions", deleted)
+ }
+ })
+ if err != nil {
+ log.Fatalf("Could not register session cleanup cron: %s", err)
+ }
+}
diff --git a/pkg/models/sessions_permissions.go b/pkg/models/sessions_permissions.go
new file mode 100644
index 000000000..a3772d74f
--- /dev/null
+++ b/pkg/models/sessions_permissions.go
@@ -0,0 +1,43 @@
+// Vikunja is a to-do list application to facilitate your life.
+// Copyright 2018-present Vikunja and contributors. All rights reserved.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package models
+
+import (
+ "code.vikunja.io/api/pkg/web"
+ "xorm.io/xorm"
+)
+
+func (sess *Session) CanDelete(s *xorm.Session, a web.Auth) (bool, error) {
+ // Link share tokens must not be able to delete user sessions.
+ if _, is := a.(*LinkSharing); is {
+ return false, nil
+ }
+
+ session, err := GetSessionByID(s, sess.ID)
+ if err != nil {
+ return false, err
+ }
+ if session.UserID != a.GetID() {
+ return false, nil
+ }
+ *sess = *session
+ return true, nil
+}
+
+func (sess *Session) CanCreate(_ *xorm.Session, _ web.Auth) (bool, error) {
+ return true, nil
+}
diff --git a/pkg/models/setup_tests.go b/pkg/models/setup_tests.go
index 741bfbbbb..60353a495 100644
--- a/pkg/models/setup_tests.go
+++ b/pkg/models/setup_tests.go
@@ -74,6 +74,7 @@ func SetupTests() {
"project_views",
"task_positions",
"task_buckets",
+ "sessions",
)
if err != nil {
log.Fatal(err)